name: firstly
description: Firstly-specific patterns on top of Remult - FF_Entity (with built-in changelog), BaseEnum, ff (reactive Svelte layer, many/one), the cell layer (buildCells, FF_Cell, boutique FF_Grid/FF_Group), published modules (mail, cron, changeLog), and the Boutique copy-paste recipes (auth, grid). Use when the user mentions firstly, FF_Entity, BaseEnum, ff/FF_Many/FF_One, buildCells/FF_Cell/FF_Grid/FF_Group, firstly/mail, firstly/cron, or the boutique folder, or when building with firstly alongside Remult. Framework-agnostic but SvelteKit is the reference setup.
Firstly Patterns
firstly is a thin, opinionated layer on top of remult. It ships two kinds of things:
- ๐ฆ Modules - published inside the
firstlypackage (firstly/mail/server,firstly/cron/server, ...). Import, register, forget. - ๐๏ธ Boutique - recipes under
packages/firstly/src/boutique/*that you copy into your own codebase and own from then on.
If you'd want to edit it, take the boutique version. If you just want it to work and upgrade cleanly, take the module version.
For generic Remult rules (repo, permissions, migrations, etc.), see the remult skill.
Use FF_Entity, Not @Entity
In a firstly project, always use FF_Entity. It's a drop-in for @Entity with the same signature, plus changelog wired in. One more abstraction - just use it.
import { Fields } from 'remult'
import { FF_Entity } from 'firstly'
@FF_Entity<Task>('tasks', {
allowApiCrud: true,
saved: async (entity, event) => {
if (event.isNew) {
/* ... */
}
},
})
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
}
BaseEnum - Richer Enums
Extends the @ValueListFieldType pattern with caption, icon, filter where, and a hide flag - useful when enums drive UI directly.
import { getValueList, ValueListFieldType } from 'remult'
import { BaseEnum } from 'firstly'
@ValueListFieldType()
export class TaskStatus extends BaseEnum {
static Todo = new TaskStatus('todo', { caption: 'To do' })
static Done = new TaskStatus('done', { caption: 'Done', hide: true })
}
for (const s of getValueList(TaskStatus)) {
// s.id, s.caption, s.icon, s.hide...
}
BaseEnum's constructor takes (id, options) - no need to redeclare fields on each subclass.
Installing Firstly
npm add firstly@latest -D
No CLI, no scaffolder. Works in any Remult project.
๐ฆ Modules (import)
Register like any Remult module.
import { remultApi } from 'remult/remult-sveltekit' // or remult-next, remult-express...
import { cron } from 'firstly/cron/server'
import { mail } from 'firstly/mail/server'
export const api = remultApi({
modules: [
mail(),
cron([{ topic: 'nightly', cronTime: '0 3 * * *', onTick: () => ({ status: 'ok' }) }]),
],
})
Available today: mail, cron, changeLog, sqlAdmin. See firstly.fun for the full list.
sqlAdmin - drop-in raw SQL page
A backend BackendMethod + a <SqlAdmin /> Svelte component, both shipped from one module. Gated by Roles_SqlAdmin.SqlAdmin_Admin (or the global FF_Role.FF_Role_Admin).
// api.ts
import { sqlAdmin } from 'firstly/sqlAdmin/server'
export const api = remultApi({ modules: [sqlAdmin({ path: '/sql/admin' })] })
<!-- routes/sql/admin/+page.svelte -->
<script>
import { SqlAdmin } from 'firstly/sqlAdmin'
</script>
<SqlAdmin />
The component ships prefilled queries (DB size, table sizes, indexes, default SELECT) and logs results as for AI: <rows> in the browser console - so chrome-devtools / AI agents can grab them with list_console_messages.
FF_Allow / FF_Filter - row-level helpers
Tiny helpers for the common "owner-only" / "admin or owner" patterns. FF_Allow is for allowApi* (per-row predicates), FF_Filter is for apiPrefilter / backendPrefilter (where-clauses). Both default the column name to 'userId'.
Pass the entity as a generic (FF_Allow.owner<Task>(...)) for autocomplete and type-safety on the column name.
import { Fields } from 'remult'
import { FF_Allow, FF_Entity, FF_Filter } from 'firstly'
import { Roles } from '$lib/roles'
@FF_Entity<Task>('tasks', {
// Owner-only writes:
allowApiUpdate: FF_Allow.owner<Task>('userId'),
allowApiDelete: FF_Allow.owner<Task>(), // defaults to 'userId'
// Admin OR owner on writes:
// allowApiUpdate: FF_Allow.ownerOr<Task>({ roles: [Roles.Admin] }),
// Admin sees all, anyone else only their own:
apiPrefilter: () => FF_Filter.ownerOr<Task>({ roles: [Roles.Admin] }),
})
export class Task {
@Fields.id() id!: string
@Fields.string() userId = ''
}
API:
FF_Allow.owner<T>(col?)/FF_Filter.owner<T>(col?)- owner-only.FF_Allow.ownerOr<T>({ col?, roles })/FF_Filter.ownerOr<T>({ col?, roles })- admin (or any ofroles) OR owner.
ff - reactive layer (Svelte 5)
ff (from firstly/svelte) exposes a Remult entity as Svelte runes. Two shapes, both take a
reactive options getter; read reactive state (items/draft/loading/error/...) in markup.
Imperative work stays on remult's repo(E). Full chapter:
firstly.fun /docs/svelte/ff.
<script lang="ts">
import { ff } from 'firstly/svelte'
// many = a list + an editing draft + writes. strategy: 'listen' | 'load' | 'paginate'
const tasks = ff(Task).many(() => ({ where: { done: false } }), 'listen')
// one = a single bound record in `item`
const editor = ff(Task).one(() => ({ where: { id }, enabled: !!id }))
</script>
{#each tasks.items as t (t.id)}{t.title}{/each}
Key rules:
- Two shapes only.
ff(E).many(getter, strategy?)owns the list (items) and the editingdraftplus the writes.ff(E).one(getter)is a single record bound toitem. The fetchstrategyis'paginate'(default: page +$count+more()),'listen'(liveQuery, auto-updates), or'load'(a static one-shot). - The getter is reactive - change
where/orderBy/enabled/pageSizeand it re-fetches (stale responses dropped).orderBydefaults to the entity'sdefaultOrderBy. Read SvelteKitloaddata through a$derived, never raw in the getter.enabled: falseskips the query until it flips true. - Editing (
many):edit(row)loads a row intodraft(pass the row, not its id - so it works with any PK incl. compositeid: ['a','b']);create(...)starts a blank draft; arglesssave()/remove()act on thedraft;save(row)/remove(row)target any row;cancel()drops the draft (and clearserror). The list reconciles automatically (load= sorted upsert,paginate= refresh,listen= liveQuery). A failed write fillserrorand re-throws. edithas two modes (why): defaultedit(row)edits an isolated clone - instant (no fetch, no flicker), saving updates (the clone keeps remult's existing-row state), andcancel()leaves the list untouched. That's the "edit the row in front of me" case, so it's the default.edit(row, { refetch: true })re-reads fresh first (async,draftbrieflyundefinedโ guard{#if draft}) for when the list may be stale and you want the latest server values before editing.- Action+confirm orchestration (
many) - the confirm/show/cancel dance, on the handle:confirmRemove(row, { message?, danger?, toast?, ... })(confirm โremove(row)โ autotoast.fromErroron failure; resolves{ ok }, never re-throws - safe foronclick={() => list.confirmRemove(row)}), andeditInDialog(row, body, { refetch? })/createInDialog(body, { defaults? })(seeddraftโdialog.show(body)โ alwayscancel()on close). Thebodysnippet bindsdraftand callssave()itself (so a failed/validation save keeps the dialog open viaerror); these just own the seed + cleanup. - Single record (
one): bind a form toitem; arglesssave()/delete()act on it;create(...)seeds a draft;refresh()re-fetches.onFirst((latest) => ...)(on bothmanyandone) seeds editable$stateonce and never re-fires - why: a live source would otherwise re-run a$derived/$effecton every tick and clobber an in-progress edit. Read-only display โ$derived(handle.items[0]);onFirstonly when the seed must become editable. - Loading:
loading={ init, fetching, more, saving, deleting };isBusy/isWritingare derived rollups. Paginate-only:hasNextPage,more(),aggregates.$count(free, same request). - No
.repoon the handle - imperative reads/writes go through remult directly:repo(E).insert/update/save/delete/deleteMany/findFirst/findId/count/.....metais kept on every handle (labels/permissions):r.meta.fields.<f>.caption,r.meta.apiInsertAllowed()/apiUpdateAllowed(item)/apiDeleteAllowed(item)/apiReadAllowed. Nocan*helpers. - Reactive vs imperative:
many/onebuild an$effect, so create them at component init. For a click handler / async fn (no runes context) use remult'srepo(E)(plain values, returns a Promise). - Make
items[0]reliable: "latest" follows yourorderByand the real SQL column type. Keep a datetime astimestamptz(a@Fields.date()stored as SQLdateties same-day rows and makesdate descnon-deterministic; Remult won't ALTER an existing column, so verify at the DB).
Types: FF_Many<T, Strategy>, FF_One<T>, FF_Builder<T>, FF_RepoOptions, ManyStrategy.
Cell layer - metadata-driven grid & form (Svelte 5)
Grids and forms are built from field metadata, in two halves (see firstly.fun /docs/svelte/cell):
- ๐ฆ Published (
firstly/svelte):buildCells(meta, cells?),displayCell,<FF_Cell>,<FF_CellValue>(renders a cell's value incl. the component escape),<GroupFields>(shared form body),DefaultInput, theFF_Config.cellregistry, thehubentity config + types. Plus<FF_Grid>โ the batteries-included demo grid (default skin + bundled input, zero setup:import { FF_Grid } from 'firstly/svelte'; just mount<FF_DialogManager>once). - ๐๏ธ Boutique (
src/boutique/grid, copy-own โ degit when you want to own the look):App_Grid(CRUD grid),App_Group(bound record),Input.FF_= firstly publishes it;App_= your app's.
Key rules:
- Metadata is SSoT. Per-field UI hints live on the field via
ui(a firstly augmentation of remultFieldOptions):width/marginLeft/marginRightare % of the row, plusalign,inputType(override the editor),order, andmobile: {โฆ}(screens<= 40rem). Alsoplaceholderandhref: (row)=>string(renders afield_link). Escape hatches on aCellInputconfig:cellSnippet,component(a lazy() => Comp/() => import('./x.svelte')thunk) +props+rowToProps,sortable(columns sort by default; per-cell wins),class. - Sortable default is
true; flip it withdefaultSortable: falseon thehub(per-entity) orFF_Config.cell(app-wide). Per-cellsortablealways wins. - Entity hub = SSoT config. Declare the grid/form config on the entity via the
huboption (@FF_Entity<E>('x', { hub: { cells, defaultSortable?, where, orderBy, strategy, pageSize, insert, update, delete } })).FF_Grid/FF_Groupread it as defaults; every prop overrides. Ahubwhosecellsreference field keys NEEDS the explicit generic (@FF_Entity<E>), else@Entitytype inference breaks. Keephuba plain object (server-safe) -components must be lazy thunks.insert/update/deleteare per-actionActionConfig({}on,falseoff); an action'scellsomitted = inherit the list cells. - Input registry. Register which component renders each
inputTypeonce at app root:<FF_Config cell={{ inputs: { text: Input, number: Input, checkbox: Input } }}>. firstly ships no styled input - thegridboutique gives a token-onlyInputto copy. - Read config at init. Components call
ffConfig()/getCellElementConfig()at component init only (Svelte 5 context) - never in a$derivedor markup. The dialog is portaled to the app root (outside the page<FF_Config>), soFF_Gridcapturesconst cfg = ffConfig()and re-provides<FF_Config cell={cfg.cell}>inside the dialog. - The grid (
FF_Gridpublished /App_Gridboutique โ same code) sits onff(E).many(all three strategies).cells= columns (defaulthub.cells); the create/edit forms useinsert.cells/update.cells(default: inheritcells).+ New/Editdisable frommeta.apiInsertAllowed()/apiUpdateAllowed(row). Cell values render viaFF_CellValue. - UI naming โ security. Dropping a field from
insert.cellsis UX only. Enforce on the field:@Fields.boolean({ allowApiUpdate: (t) => !getEntityRef(t).isNew() })makes it settable on edit but not insert (the API rejects it). The two are complementary - lock on the field, mirror in the UI. App_Group(boutique) is one bound record (ff(E).one): a form whenmode="edit", values whenmode="readonly"; both modes share a height so toggling doesn't shift the page. The grid's dialog andApp_Groupboth render the publishedGroupFields, so a field looks identical inline or in a dialog.- Published vs boutique.
FF_Grid(batteries demo) +GroupFields+DefaultInputARE published;App_Grid/App_Group/Inputare the copy-own boutique (degit .../src/boutique/grid).
dialog - headless dialogs (Svelte 5)
dialog (from firstly/svelte) is an async dialog layer. Every dialog.* call resolves the same
DialogResult ({ ok: true, data } | { ok: false }) - ALWAYS read .ok (or destructure { ok });
never use the result as a boolean, the object is always truthy so if (await dialog.confirm(...))
silently always passes. dialog.show(body, opts) (body = snippet receiving close(result?)) and
dialog.open(component, { props }) resolve { ok, data }; dialog.confirm(message, { title?, danger?, confirmLabel?, cancelLabel? }) resolves { ok } (no data); dialog.prompt({...})
resolves { ok, data: string }.
Mount <FF_DialogManager /> once at the app root: it's headless (owns esc / scroll-lock /
stacking) and renders built-in default shell + confirm styled in semantic Tailwind tokens
(bg-card, border-border, bg-primary, bg-destructive, ...) so they inherit the app theme with
zero config. Pass shell / confirm snippets to fully restyle. Confirm labels are LocalizedMessage.
toast - notifications (Svelte 5)
toast (from firstly/svelte) is a thin wrapper over svelte-sonner
(a direct firstly dependency - consumers install nothing). Mount <FF_ToastManager /> once (it renders
sonner's <Toaster>; sonner props via <FF_Config toast={{ position, richColors, ... }}>).
The first arg is the description (the body) and may contain HTML. A bold title sits above
it; it defaults per kind (error โ "Error", โฆ) and is overridable via opts.title:
toast.error('Could not save the quote') // title "Error" + body
toast.success('Saved <b>3</b> rows', { title: '๐ Done' })
toast.fromError(err) // error toast from any thrown value
toast.success / error / info / warning (description, { title?, duration?, action? }),
toast.show(description, { kind? }), toast.fromError(err), toast.dismiss(id?). Labels are
LocalizedMessage (string or message fn), resolved at call time. Per-kind default titles are
localizable via <FF_Config messages={{ toast: { error, success, info, warning } }}> (pass message
functions for i18n). many.confirmRemove uses toast.fromError on a failed delete.
Security: the description renders as HTML - pass only trusted/sanitized content, never raw
user or network/error text (XSS). toast.fromError HTML-escapes its extracted message, so error text
is always safe to show; titles are always plain text.
i18n - LocalizedMessage
firstly's localizable-string convention (from createValidators, reused by dialog.confirm):
type LocalizedMessage = string | (() => string)
A literal for single-locale apps, or a function resolved at render / validation time - typically a
paraglide / i18next / lingui message function, so it tracks the current locale. firstly resolves it
with resolveMessage(m) (typeof m === 'function' ? m() : m). Pass the message function (not a
pre-resolved string) so locale switches stay reactive:
import * as m from '$lib/paraglide/messages'
dialog.confirm(m.delete_confirm, { confirmLabel: m.delete, danger: true })
๐๏ธ Boutique (copy-paste)
Grab a boutique recipe with degit:
npx degit jycouet/firstly/packages/firstly/src/boutique/auth src/modules/auth
Once copied, it's your code. Rewire imports (use your framework's env convention, e.g. $env/static/private in SvelteKit), adjust UI, plug in providers. Register its module the same way as an imported one:
import { auth } from '$lib/modules/auth/server/module'
export const api = remultApi({ modules: [auth({ SUPER_ADMIN_EMAILS })] })
Full instructions live in each boutique's README.
Roles Convention
Each module exposes a Roles_<ModuleName> object and users merge them into one app-wide Roles.
// app/roles.ts
import { Roles_Cron } from 'firstly/cron'
import { Roles_Mail } from 'firstly/mail'
import { Roles_SqlAdmin } from 'firstly/sqlAdmin'
import { Roles_Auth } from '$lib/modules/auth/entities'
export const Roles = {
Admin: 'admin',
...Roles_Auth, // Auth.Admin
...Roles_Mail, // Mail.Admin
...Roles_Cron, // Cron.Admin
...Roles_SqlAdmin, // SqlAdmin.Admin
} as const
Use Roles.* in allowApi* decorators and assign them to users via the auth boutique's addRolesToUser helper or SUPER_ADMIN_EMAILS.
Naming - FF_ Prefix
Types and helpers exported by firstly that could collide with user code use the FF_ prefix: FF_Entity, FF_Role, FF_Allow, FF_Filter, FF_Icon, FF_LogToConsole, FF_Many / FF_One (reactive handle types). If you see it in an import path, it's firstly's. Factory functions stay camelCase (e.g. ff).