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

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 new table in a databasecreate it yourself, with the tooling you already use

For the last case, keep using whatever owns your schema. If the project already uses Prisma or Drizzle, keep them — sync reads through their client, and their migrations stay the source of truth. Wire them into Farm so the command runs with everything else:

farm.config.ts
export default defineConfig({
  migrations: {
    commands: ["pnpm prisma migrate deploy"],
  },
});

For a project with no ORM, a raw pg, sqlite, or mysql connection is the simplest option: sync drives it directly, and there is no second migration system to keep in step. For now that means writing the CREATE TABLE yourself once; a command that emits it from the schema is planned (#1297).

If a query fails with relation "tasks" does not exist, the table has not been created yet — sync will not create it for you.

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 { db } from "@farm.js/sync/client";
import { useLiveQuery } from "@farm.js/sync/react";

export default function TasksPage() {
  const open = useLiveQuery(db.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 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
pausedwrites waiting for the connection to return
isEmptyno rows matched

useRow(db.tasks, id) subscribes to a single row.

Write

db.tasks.insert({ title: "Write the RFC" });
db.tasks.update({ id, status: "done" });
db.tasks.delete({ id });

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.

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

Because a rejected write reverts silently, surface the reason:

const [error, setError] = useState<string | null>(null);

<button
  onClick={() => {
    setError(null);
    db.tasks.update({ id, status: "done" }).catch((cause) => setError(cause.message));
  }}
>
  Done
</button>;

{
  error && <p role="alert">{error}</p>;
}

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.

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);
await clearSyncedRows(); // on logout, so a shared device stays clean

Operations beyond table writes

Anything that is not a row-level change stays an ordinary server function, and composes with collections through invalidation:

export const archiveProject = createServerFn({
  input: z.object({ projectId: z.string() }),
  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.