Sync Plugin
@farm.js/sync gives a model rows that live in the browser. Reads render from a
local store, writes apply before the server answers, edits made offline queue
until the connection returns, and a revisit paints from disk.
It is a layer over your existing data, not a replacement for it: every read and write goes through your database, filtered by a rule you control on the server.
Choosing between this and the patterns guide
Local-first patterns covers what Farm's cache, mutations, and persistence already do for ordinary queries and server functions — no new package. Reach for this plugin when you want the unit of data to be a row rather than a cached response: one row shown in several views, updated once.
Install
pnpm add @farm.js/syncDeclare the data once
import { defineSchema } from "@farm.js/core";
export const schema = defineSchema({
models: {
tasks: {
fields: {
id: { type: "uuid", primaryKey: true },
title: { type: "string", required: true },
status: { type: "enum", values: ["open", "done"], default: "open" },
orgId: { type: "uuid", required: true, index: true },
updatedAt: { type: "datetime" },
},
},
},
});Field metadata does real work, so configuration does not repeat it:
| Declaration | What it decides |
|---|---|
primaryKey: true | the row key |
type: "enum" + values | the column's type, and validation before the optimistic write |
reference | relationships between models |
datetime named updatedAt | incremental sync: after the first load only changed rows are sent |
name on a model or field | the real table or column name in an existing database |
Configure the plugin
import { defineConfig } from "@farm.js/core";
import { sync } from "@farm.js/sync";
import { schema } from "./src/schema";
export default defineConfig({
plugins: [
sync({
schema,
storage: "app",
models: { tasks: "write", projects: "read" },
middleware: [withOrg],
where: ({ context }) => ({ orgId: context.org.id }),
}),
],
});Options
| Option | Purpose |
|---|---|
schema | the declarative schema above |
storage | a mount name from storage.mounts |
client | your database instead of a mount; see below |
models | which models the browser may touch: "write", "read", or false |
where | the row filter applied to every read and write, server side |
middleware | request middleware producing the context where reads |
persist | keep rows on the device for warm starts. Default true |
path | the endpoint the browser calls. Default /_farm/sync |
allowedOrigins | extra origins allowed to call the endpoint, in the serverActions.allowedOrigins pattern syntax |
Point it at your database
storage names a Farm mount and needs no other setup, which makes it the
quickest way to run the plugin. A mount is key-value, so it stores rows as
serialized values rather than in tables you can query with SQL.
For a real database, pass the connection:
import { Pool } from "pg";
sync({
schema,
client: () => new Pool({ connectionString: process.env.DATABASE_URL }),
models: { tasks: "write" },
where: ({ context }) => ({ orgId: context.org.id }),
});@farming-labs/orm detects which driver the connection needs and builds the
data layer from your schema, so the same configuration works for a pg pool, a
Drizzle or Prisma client, a D1 binding, or a Mongo client. Sync never generates
SQL and has no per-database code path.
Where the tables come from
Sync reads and writes rows. It never creates or alters tables, so what you need before the first query depends on where the data lives.
| Setup | What you do first |
|---|---|
storage: "app" | nothing. A mount is key-value, so there is no table to create |
| A database you already use | nothing. Describe the existing tables with name mappings, below |
A raw pg, sqlite, or mysql connection | farm sync migrate |
| A database behind Prisma or Drizzle | their own migration, as usual |
If a query fails with relation "tasks" does not exist, the table has not been created yet — sync will not create one mid-request.
farm sync migrate
When sync talks to a database connection directly, it can create the tables your
schema describes. The command prints the SQL and stops; --apply runs it:
pnpm farm sync migrate-- Generated by `farm sync migrate` from the sync schema.
-- Dialect: postgres
-- Review before applying. Farm never alters existing tables.
CREATE TABLE IF NOT EXISTS "tasks" (
"id" TEXT PRIMARY KEY,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"orgId" TEXT NOT NULL,
"updatedAt" TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS "tasks_orgId_idx" ON "tasks" ("orgId");Only models you listed in models are created. A model declared in the schema
but never exposed to the browser belongs to the rest of your app, so sync leaves
it alone.
Existing tables are reported, never altered, and an ORM-owned schema should keep using its own migrations. See Plugin Tables for the flags, the drift output, and how any plugin declares tables this way.
Syncing tables you already have
Describe the existing tables instead of creating new ones. name maps a model
to its table and a field to its column:
tasks: {
name: "todo_items",
fields: {
id: { type: "uuid", primaryKey: true },
title: { type: "string", name: "item_title" },
orgId: { type: "uuid", name: "organization_id", index: true },
updatedAt: { type: "datetime", name: "updated_at" },
},
},No migration runs. Models you do not describe stay invisible to the browser.
Read
"use client";
import { useLiveQuery } from "@farm.js/sync/react";
export default function TasksPage() {
const open = useLiveQuery("tasks", (task) => task.status === "open", {
orderBy: (task) => task.updatedAt,
direction: "desc",
});
if (open.status === "loading") return <Skeleton />;
return (
<ul>
{open.rows.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}The model name is not a loose string. Farm generates a SyncModels map into
src/farm.d.ts from the schema, so "tasks" autocompletes, a wrong name is a
compile error, and task in the predicate is fully typed, enum unions
included. There is nothing to import or instantiate for this: write the
schema, and the types follow.
The predicate runs in the browser over rows already on the device, so any expression is fine. Which rows reach the device is decided separately, by the server's row filter.
| Field | Meaning |
|---|---|
rows | matching rows |
status | "loading" only on a genuine cold start; a revisit with persistence on is "ready" |
error | a failed read. Previously loaded rows stay visible |
pending | writes in flight against this model |
queued | writes waiting for the connection to return (paused is the same number) |
isEmpty | no rows matched |
isPersisted(row) | true once the server confirmed the row; false while it is optimistic |
failures | rolled-back writes awaiting the user; see when a write fails |
useRow("tasks", id) subscribes to a single row.
Deletions and scope changes
With an updatedAt cursor, a load after the first asks only for rows changed
since the last one. A delta cannot describe a row that no longer exists, so the
server also sends the key of every row the caller can still see, read with the
same row filter as the list. The browser drops any row it holds whose key is
missing. That covers rows deleted by another client, rows deleted straight in
the database, and rows that left the caller's scope, for example a task moved
to another organization.
Rows written in the browser are safe from this: an insert the server has not
confirmed yet is never dropped, and neither is a row confirmed while the load
was in flight. The key list costs one extra key-only query per incremental
load. Without an updatedAt field every load is a full snapshot, which already
replaces the rows on the device.
Write
Reading and writing are separate surfaces: useLiveQuery observes, and
useSyncAction with a model name is the plain write surface. A wrong model
name or field on either side is a compile error.
const tasks = useSyncAction("tasks");
tasks.insert({ title: "Write the RFC" });
tasks.update(id, { status: "done" });
tasks.delete(id);The write methods that shipped on the live query result remain as deprecated aliases; new code should keep queries read-only.
Each call applies to the local store immediately, so every view showing that row
re-renders in the same frame, then persists in the background. Fields the schema
fills in — the key, defaults, the cursor, and the columns owned by where — can
be omitted, and the generated types know it: insert marks them optional.
Every write returns a handle, usable whichever way suits the call site:
tasks.insert({ title }); // fire and forget
await tasks.insert({ title }); // wait for the server
await tasks.insert({ title }).persisted; // the same, named explicitly
tasks.insert({ title }).catch(showError); // handle the failure inline
const handle = tasks.insert({ title });
handle.key; // the row key, available before any network call
handle.state; // "pending" | "paused" | "completed" | "failed"Ignoring the handle is safe: a write the interface already rolled back does not surface as an unhandled rejection.
What happens when a write fails
| Situation | Behavior |
|---|---|
| Server rejects it | the row reverts to its previous values, and the handle rejects |
| Network error or 5xx | retried with backoff; the row stays on screen throughout |
| Offline | the write pauses, paused increments, and it sends itself on reconnect |
| Reload while pending | the pending write is lost; optimistic state is in memory only |
| Deleting a gone row | treated as done, so the row stays removed instead of reappearing |
A rejected write reverts on screen, but it does not vanish: it lands in the
model's failures queue with the exact input that failed, the reason, and two
verbs. Render the queue once and every rollback in the app is accounted for:
{
tasks.failures.map((failure) => (
<p key={failure.id} role="alert">
{failure.error.message}
<button onClick={() => failure.retry()}>Retry</button>
<button onClick={() => failure.dismiss()}>Dismiss</button>
</p>
));
}retry() re-applies the optimistic state and sends the write again; a second
refusal records once, not once per attempt. failures is a queue rather than a
single error slot because fire-and-forget writes can fail out of order, minutes
after the gestures that caused them.
Security
where is the boundary, and it is enforced by the query rather than by checking
what the browser sent:
where: ({ context }) => ({ orgId: context.org.id });A client calling db.tasks.update({ id: "another-org-row" }) produces a query
filtered by both the id and the scope, so it matches nothing and returns
not_found. Three consequences follow:
- Columns named by
whereare server-owned. They are injected on insert and ignored when a client sends them, so no separate read-only list is needed. - Synced rows are scoped too. A device only holds rows that passed the filter, so the local store cannot leak another tenant's data offline.
- A writable model with no filter fails the build. Set
where: falseon a model to expose every row deliberately.
The context where reads comes from your own middleware, the same kind used by
server functions:
const withOrg = createServerMiddleware({
async handler({ request, next }) {
const session = await getSession(request);
if (!session.user) throw new UnauthorizedError();
return next({ context: { org: await loadOrg(session.user.orgId) } });
},
});Sync never inspects authentication itself; it reads the object your middleware built, so any auth provider works.
Because operations ride the caller's cookies, the endpoint also enforces the
same origin contract as server actions: a POST from another site — including a
simple request that skips the CORS preflight — is rejected with 403 before
the body is read. A trusted second origin, such as a separate marketing site
embedding the app, goes in allowedOrigins.
Persistence
Rows are stored in the browser so a revisit paints before the network answers. Entries load stale-but-visible and are revalidated on the first read, so disk data is never treated as fresh.
Only server-confirmed rows are written; an optimistic guess in flight never
reaches disk. Set persist: false to disable it, or replace the store:
import { setSyncPersistence, clearSyncedRows } from "@farm.js/sync/client";
setSyncPersistence(myIndexedDbStore);
clearSyncedRows(); // whenever the session changesCall clearSyncedRows() whenever the session changes without a page reload:
sign-out, signing in as someone else, or switching organization. It clears
what is on disk and in memory, including the incremental cursor, and drops any
load or write still in flight for the previous session. Each model then
reloads in full on its next use, scoped to whoever is signed in at that point.
Server-ruled transitions
A plain write cannot express "the server gets to say no before it becomes
true". Transitions with rules — complete, claim, approve — are ordinary server
functions, written once against your real database, and bound to a model with
useSyncAction so calling one behaves like every other sync write:
export const completeTask = createServerFn({
async handler({ input, request }) {
const task = await db.tasks.findFirst({ where: { id: input.id, ...scope(request) } });
if (task?.status !== "open") throw new Error("Only an open task can be completed.");
return db.tasks.update({ where: { id: input.id }, data: { status: "done" } });
},
});import { completeTask } from "../actions";
const complete = useSyncAction(completeTask, "tasks", {
optimistic: { status: "done" },
});
complete({ id: task.id }); // fire and forget, like any other writeThe optimistic patch shows the transition instantly; the row the handler
returns replaces it when the server confirms, and a refusal rolls it back into
the same failures queue plain writes use. Every useLiveQuery over the model
follows along — the action writes the same store the queries read.
The patch resolves in tiers, so most actions declare nothing:
- Derived. When the input carries the model's key plus fields that are
schema columns —
rename({ id, title })— those fields are the patch. - Declared. For transitions the input does not spell out, pass
optimisticas a patch object, or as(input) => patchwhen it depends on the input. - None. Skip both and the action still lands: the returned rows commit into the store on arrival. Only the instant preview is missing.
The handler returning the changed row (or an array of rows) is what makes the commit precise; return nothing row-shaped and the model refreshes instead. Do not put the outcome in the input to game tier one — the handler owns the transition, the patch is only the client's drawing of it.
Anything that is not a row-level change at all stays a plain server function and composes through invalidation:
export const exportBoard = createServerFn({
invalidates: () => [{ key: ["tasks"] }],
async handler({ input, context }) {
/* ... */
},
});Limits
- Live cross-user updates are not implemented. Rows refresh on load and after your own writes; another user's change appears on the next load.
- A pending write does not survive a reload. Durable offline queueing is tracked separately.
storagemounts are key-value. Pointclientat a database when you need your existing relational tables.- Tables are never created or altered. Sync only reads and writes rows; see where the tables come from.
Example
examples/local-first is a runnable task list covering instant writes, warm
starts, offline queueing, and scope enforcement.
Turn a schema into browser-side collections with optimistic writes, offline queueing, and warm starts, backed by your own database.