Skip to content

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-updates
ffRepo(E).paginate(() => ({ where })) // paginate - more() / hasNextPage / aggregates
ffRepo(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 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} />

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} />
OptionModesNotes
whereallRemult EntityFilter.
orderByallDefaults to the entity’s defaultOrderBy.
includeallRelations to load.
enabledallfalse skips the query (keeps last result) until it flips true.
limitfind / one / liveCap rows. No default - returns every matching row.
pageSizepaginateRows per page (default 25).
aggregatepaginateAggregations computed alongside the page (see below).
PropertyTypeModes
itemsEntity[]all
itemEntity | undefinedone / create slot
loading{ init, fetching, more, saving, deleting }all
errorstring | undefinedall
hasNextPagebooleanpaginate
aggregatestyped result ($count + requested)paginate

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 -> number

Want a total but not pagination? Prefer paginate() anyway. load/listen/one don’t count; for a one-off count use ffRepo(E).repo.count(where).

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)).

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”).

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().

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 id
r.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 flow
if (ok) r.removeItem(id) // instant, keeps your loaded pages

Want authoritative totals/ordering instead? Call refresh() (it re-pulls and, for paginate, resets to the first page).

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}

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, …).

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.

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 handle
let { paged }: { paged: FF_RepoPaginate<Task> } = $props() // requires .more()/.aggregates

Per-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.

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: trueenabled: false (runs when it flips true)
r.globalErrorr.error
r.fieldsr.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?.$countr.aggregates?.$count (unchanged)