Farm.js

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/sync

Declare the data once

src/schema.ts
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:

DeclarationWhat it decides
primaryKey: truethe row key
type: "enum" + valuesthe column's type, and validation before the optimistic write
referencerelationships between models
datetime named updatedAtincremental sync: after the first load only changed rows are sent
name on a model or fieldthe real table or column name in an existing database

Configure the plugin

farm.config.ts
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

OptionPurpose
schemathe declarative schema above
storagea mount name from storage.mounts
clientyour database instead of a mount; see below
modelswhich models the browser may touch: "write", "read", or false
wherethe row filter applied to every read and write, server side
middlewarerequest middleware producing the context where reads
persistkeep rows on the device for warm starts. Default true
paththe endpoint the browser calls. Default /_farm/sync
allowedOriginsextra 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.

SetupWhat you do first
storage: "app"nothing. A mount is key-value, so there is no table to create
A database you already usenothing. Describe the existing tables with name mappings, below
A raw pg, sqlite, or mysql connectionfarm sync migrate
A database behind Prisma or Drizzletheir 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

src/app/tasks/page.tsx
"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.

FieldMeaning
rowsmatching rows
status"loading" only on a genuine cold start; a revisit with persistence on is "ready"
errora failed read. Previously loaded rows stay visible
pendingwrites in flight against this model
queuedwrites waiting for the connection to return (paused is the same number)
isEmptyno rows matched
isPersisted(row)true once the server confirmed the row; false while it is optimistic
failuresrolled-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

SituationBehavior
Server rejects itthe row reverts to its previous values, and the handle rejects
Network error or 5xxretried with backoff; the row stays on screen throughout
Offlinethe write pauses, paused increments, and it sends itself on reconnect
Reload while pendingthe pending write is lost; optimistic state is in memory only
Deleting a gone rowtreated 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 where are 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: false on 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 changes

Call 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:

src/actions.ts
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" } });
  },
});
src/app/tasks/page.tsx
import { completeTask } from "../actions";

const complete = useSyncAction(completeTask, "tasks", {
  optimistic: { status: "done" },
});

complete({ id: task.id }); // fire and forget, like any other write

The 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:

  1. Derived. When the input carries the model's key plus fields that are schema columns — rename({ id, title }) — those fields are the patch.
  2. Declared. For transitions the input does not spell out, pass optimistic as a patch object, or as (input) => patch when it depends on the input.
  3. 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.
  • storage mounts are key-value. Point client at 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.