ffRepo (FF_Repo)
ffRepo is a thin reactive wrapper around a Remult repo, exposing query results as Svelte 5
runes. You pick a mode with a verb, hand it a reactive options getter, and read reactive
state (items, loading, error, …) straight in your markup. Writes (insert/update/save/
delete) keep that state in sync for you.
<script lang="ts"> import { ffRepo } from 'firstly/svelte'
import { Task } from '$lib/Task'
const tasks = ffRepo(Task).load(() => ({ where: { done: false } }))</script>
{#if tasks.loading.init} Loading…{:else} {#each tasks.items as t (t.id)} <li>{t.title}</li> {/each}{/if}Pick the mode that matches what you need. The return type is mode-specific - e.g. .more()
only exists on a paginate() handle.
ffRepo(E).load(() => ({ where })) // load - one-shot list + refresh()ffRepo(E).listen(() => ({ where })) // live - liveQuery, auto-updatesffRepo(E).paginate(() => ({ where })) // paginate - more() / hasNextPage / aggregatesffRepo(E).one(() => ({ where })) // one - a single reactive record in `item`Always pick a verb - there is no bare two-arg form.
One rule splits the surface: anything not under .repo is reactive (a verb returns a
runes handle whose writes sync its own state); anything under .repo is the plain remult
repo - imperative, returns Promises, touches no runes state.
The options getter is reactive
Section titled “The options getter is reactive”The getter re-runs whenever the state it reads changes - a search box, an orderBy toggle, a route
param. load/paginate/one re-fetch (stale in-flight responses are dropped); listen
re-subscribes. orderBy defaults to the entity’s defaultOrderBy.
<script lang="ts"> let q = $state('') const r = ffRepo(Task).load(() => ({ where: q ? { title: { $contains: q } } : {}, enabled: q.length >= 2, // skip until 2 chars; keeps the last result meanwhile }))</script>
<input bind:value={q} />Search across fields
Section titled “Search across fields”For a “NOM Prénom” style search box (every word must match, in any of the fields), use
FF_Filter.containsWords to build the where - word order and which field holds which word don’t
matter:
<script lang="ts"> import { repo } from 'remult' import { FF_Filter } from 'firstly' import { ffRepo } from 'firstly/svelte'
let q = $state('') const f = repo(User).fields const r = ffRepo(User).paginate(() => ({ where: FF_Filter.containsWords([f.name, f.email], q), enabled: q.length >= 2, }))</script>
<input bind:value={q} />Options
Section titled “Options”| Option | Modes | Notes |
|---|---|---|
where | all | Remult EntityFilter. |
orderBy | all | Defaults to the entity’s defaultOrderBy. |
include | all | Relations to load. |
enabled | all | false skips the query (keeps last result) until it flips true. |
limit | find / one / live | Cap rows. No default - returns every matching row. |
pageSize | paginate | Rows per page (default 25). |
aggregate | paginate | Aggregations computed alongside the page (see below). |
Reactive state
Section titled “Reactive state”| Property | Type | Modes |
|---|---|---|
items | Entity[] | all |
item | Entity | undefined | one / create slot |
loading | { init, fetching, more, saving, deleting } | all |
error | string | undefined | all |
hasNextPage | boolean | paginate |
aggregates | typed result ($count + requested) | paginate |
Paginate + aggregates + infinite scroll
Section titled “Paginate + aggregates + infinite scroll”paginate returns aggregates.$count (the total) for free in the same request as the page, and
appends with more(). Pair it with the infiniteScroll attachment on a bottom sentinel:
<script lang="ts"> import { ffRepo, infiniteScroll } from 'firstly/svelte'
const r = ffRepo(Task).paginate(() => ({ pageSize: 25 }))</script>
{#each r.items as t (t.id)} <Row {t} />{/each}<p>{r.aggregates?.$count ?? '…'} total</p>
<div {@attach infiniteScroll({ hasMore: () => r.hasNextPage, loading: () => r.loading.more, onMore: () => r.more(), })}></div>Request richer aggregations - the result type is inferred:
const r = ffRepo(Order).paginate(() => ({ aggregate: { sum: ['total'] } }))// r.aggregates?.$count -> number// r.aggregates?.total.sum -> numberWant a total but not pagination? Prefer
paginate()anyway.load/listen/onedon’t count; for a one-off count useffRepo(E).repo.count(where).
A single record - one
Section titled “A single record - one”For an edit page: load one record reactively into item, bind a form to it, save. Called with no
argument, save() and delete() operate on the current item - so a bound form needs no plumbing.
<script lang="ts"> let { id } = $props() const r = ffRepo(Task).one(() => ({ where: { id }, enabled: !!id }))</script>
{#if r.item} <input bind:value={r.item.title} /> <button disabled={r.loading.saving} onclick={() => r.save()}>Save</button> <button disabled={r.loading.deleting} onclick={() => r.delete()}>Delete</button>{/if}This pairs with create() (which seeds a new draft into item): r.create() -> bind the form to
r.item -> r.save(). To save or delete a specific row instead of item, go through .repo
(r.repo.save(row), r.repo.delete(id)).
Seed editable state once - onFirst
Section titled “Seed editable state once - onFirst”onFirst(fn) runs fn a single time - the moment the first row lands (items[0]). Use it to seed
editable $state from the latest saved row without a live tick clobbering edits mid-typing: it never
re-fires on later changes (an edit, a delete, a re-sort), and empty snapshots are skipped.
<script lang="ts"> const tasks = ffRepo(Task).listen(() => ({ orderBy: { createdAt: 'desc' } })) let draft = $state({ title: '' }) tasks.onFirst((latest) => (draft.title = latest.title)) // seed once, then the input owns it</script>
<input bind:value={draft.title} />Prefer $derived for read-only state; reach for onFirst only when the seed must become
independently editable. Not available on paginate (a page isn’t “the latest”).
Mutations
Section titled “Mutations”Only the record handle (one / create()) writes: save() / delete() are argless - they
act on the current item (a missing item throws), flip loading, re-sync after, and re-throw on
failure (filling error, so a try/catch still works).
List handles (load / listen / paginate) are read-only - they expose no save/delete/
insert/update. Write through .repo, the plain remult repo:
await r.repo.insert(data) // or .update(id, data) / .save(row) / .delete(id) / .deleteMany(where)Then reflect it: a listen list re-syncs itself via the liveQuery; on load / paginate use the
client reconcilers below (addItem / updateItem / removeItem) or refresh().
Reconciling the list (client-side)
Section titled “Reconciling the list (client-side)”After a mutation you did elsewhere - via .repo, a controller, a confirm dialog - reflect it in the
reactive items without a round-trip. These are client-only (no server I/O) and live on
load/paginate (a listen handle reconciles itself through its liveQuery):
r.addItem(item) // insert at top (default)r.addItem(item, { at: 'bottom' }) // or 'top' | an index | -1 (= last)r.updateItem(updated) // replace the row with the same idr.removeItem(idOrItem) // drop the row (id or item; composite ids ok)addItem/removeItem also adjust aggregates.$count (+1 / -1); the other aggregates aren’t
recomputed. Typical flow:
await MyController.deleteWithSideEffects(id) // your server-side flowif (ok) r.removeItem(id) // instant, keeps your loaded pagesWant authoritative totals/ordering instead? Call refresh() (it re-pulls and, for paginate,
resets to the first page).
Putting it together - an inline CRUD grid
Section titled “Putting it together - an inline CRUD grid”A listen handle for the list (live - any write re-emits it, so no reconcile code) and a one
handle for the row being edited or created. The input label comes from remult metadata, not a
hardcoded string. Edit mode is keyed off editingId (not editor.item, which lingers after a save).
<script lang="ts"> import { ffRepo } from 'firstly/svelte'
import { Task } from '$lib/Task'
const list = ffRepo(Task).listen(() => ({ orderBy: { createdAt: 'desc' } }))
// the row being edited (by id) or a fresh draft for "new" - one reactive slot let editingId = $state<string | null>(null) const editor = ffRepo(Task).one(() => ({ where: { id: editingId ?? '' }, enabled: !!editingId }))
function edit(id: string) { editingId = id // → editor.item loads that row } function add() { editingId = null editor.create({ title: '' }) // blank draft into editor.item } function cancel() { editingId = null editor.item = undefined // drop the draft / stop editing } async function save() { await editor.save() // insert (a draft) or update (the loaded row); the live list self-syncs cancel() } async function remove(task: Task) { await list.repo.delete(task.id) // raw delete via .repo; the live list drops the row }</script>
<button onclick={add}>+ New</button>
{#if editor.item} <input bind:value={editor.item.title} placeholder={editor.meta.fields.title.caption} /> <button disabled={editor.loading.saving} onclick={save}>Save</button> <button onclick={cancel}>Cancel</button>{/if}
{#each list.items as task (task.id)} {#if editingId !== task.id} <li> {task.title} <button onclick={() => edit(task.id)}>Edit</button> <button onclick={() => remove(task)}>Delete</button> </li> {/if}{/each}Permissions - via r.meta
Section titled “Permissions - via r.meta”There are no can* wrappers. Use remult’s own metadata, which reflects the current remult.user:
<button disabled={!r.meta.apiInsertAllowed()}>Add</button><button disabled={!r.meta.apiDeleteAllowed(row)}>Delete</button>r.meta is also the escape hatch for fields, idMetadata, options, key. r.repo is the
escape hatch to the underlying repo (count, upsert, projections, …).
Reactive vs imperative
Section titled “Reactive vs imperative”The reactive verbs take a getter (() => ({ ... })) and build an $effect, so they must be
created during component init. For a one-off read/write in a click handler / async function (no
runes context), go through .repo - the plain remult repo (plain values, returns a Promise):
async function open(id: string) { const task = await ffRepo(Task).repo.findId(id) // or .repo.findFirst(where) // ...}Everything imperative lives on .repo: findFirst, findId, find, insert, update, save,
delete, deleteMany, create, count, upsert, … .meta is a shortcut to repo.metadata.
Type helpers
Section titled “Type helpers”For typing component props that receive a handle, use the umbrella FF_Repo<T> (any mode), or
a per-mode alias for a stricter contract:
import type { FF_Repo, FF_RepoPaginate } from 'firstly/svelte'
let { any }: { any: FF_Repo<Task> } = $props() // accepts a load/listen/paginate/one handlelet { paged }: { paged: FF_RepoPaginate<Task> } = $props() // requires .more()/.aggregatesPer-mode aliases: FF_RepoLoad, FF_RepoLive, FF_RepoPaginate, FF_RepoOne. FF_RepoBuilder
(the ffRepo(E) return), FF_RepoOptions, FF_RepoLoading, AggregateOptions and
QueryOptionsHelper are exported too.
Migrating from the old FF_Repo class
Section titled “Migrating from the old FF_Repo class”The old new FF_Repo(E, { findOptions }) class is replaced by the ffRepo() factory. The big shift:
options are now a reactive getter (() => ({ ... })) instead of an imperative find()/query()
call, so you usually delete the $effect you used to write by hand.
Old (FF_Repo class) | New (ffRepo) |
|---|---|
new FF_Repo(E, { findOptions: { where } }) | ffRepo(E).load(() => ({ where })) |
new FF_Repo(E, { queryOptions: {...} }) | ffRepo(E).paginate(() => ({ ... })) |
r.query({ where }) / r.queryMore() | reactive getter + r.more() |
r.queryRefresh() | r.refresh() |
manual $effect(() => r.find({ where: q })) | ffRepo(E).load(() => ({ where: q })) |
skipAutoFetch: true | enabled: false (runs when it flips true) |
r.globalError | r.error |
r.fields | r.meta.fields |
r.metadata.apiInsertAllowed() | r.meta.apiInsertAllowed() |
repo(r.ent).update(id, v) / .insert(v) | r.update(id, v) / r.insert(v) |
r.aggregates?.$count | r.aggregates?.$count (unchanged) |