Farm.js

Plugin Tables

A plugin that takes a schema usually needs somewhere to put the rows. Farm can create those tables from the schema itself, so neither you nor the people using your plugin have to transcribe it into SQL by hand.

Declare the tables once and every app gets a command:

pnpm farm <plugin> migrate

Using it

If a plugin you have configured declares tables, the command prints the SQL its schema implies and stops:

pnpm farm sync migrate
-- Generated by `farm sync migrate` from the sync schema.
-- Dialect: sqlite
-- 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',
  "listId" TEXT NOT NULL,
  "updatedAt" TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS "tasks_listId_idx" ON "tasks" ("listId");

Read it, then run it:

FlagWhat it does
(none)print the statements
--write <file>save them to a file to commit alongside your other migrations
--applyexecute them

migrate reports what is missing from your database, so it needs to reach it even to print. For a full dump that needs no connection, use farm generate --orm postgres|mysql|sqlite — it reads the same declarations and emits the same SQL, plus Prisma and Drizzle schemas.

The plugin name is the one the plugin declares, not its package name. Ask an app what it has by naming one that does not exist:

$ pnpm farm jobs migrate
No plugin named "jobs" owns tables in this app. Available: sync.

It only ever creates

A table that exists but no longer matches the schema is reported, not altered:

[info] These tables exist but no longer match the schema. Farm will not change them:
  tasks
    missing in the database: priority
    not in the schema: legacy_note
⚠️  Tables that differ from the schema were left unchanged.

A create is derivable from the schema alone. A change is not: a rename and a drop-plus-add look identical from here, and one of them destroys data. That call stays with the person who knows which one it was — take the column change to your own migration tooling.

Statements are emitted as IF NOT EXISTS, so --apply is safe to re-run.

When it does not apply

Two setups need nothing, and say so rather than guessing:

  • A storage mount. Key-value storage has no tables. The command reports Nothing to create and exits cleanly.

  • A schema owned by Prisma, Drizzle, or another ORM. Their migrations are the source of truth. Keep using them, and wire them into Farm so they run with everything else:

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

Declaring tables from a plugin

Wrap the plugin you already return in declareSchemaTables:

src/index.ts
import { declareSchemaTables, definePlugin } from "@farm.js/core";

export function jobs(options: JobsOptions) {
  const plugin = definePlugin({
    name: "farm:jobs",
    // ...
  });

  return declareSchemaTables(plugin, {
    name: "jobs",
    schema: options.schema,
    resolveClient: () => resolveClient(options),
  });
}

That is the whole contract. farm jobs migrate now works in any app that configures the plugin, and farm generate --orm prisma|drizzle|... includes the plugin's models alongside every integration's.

FieldPurpose
namethe <plugin> in farm <plugin> migrate
schemathe schema whose models this plugin stores
modelsmodel keys it owns; defaults to every model in the schema
resolveClientreturns the configured connection, or the storage mount
dialectonly when the dialect cannot be detected from the client's shape

Declare only what you own

A declaration claims every model in the schema. Narrow it with models when an app hands your plugin a schema it shares with the rest of its code:

models: ["jobs", "jobRuns"],

A model the plugin was never given control of is not its table to create. @farm.js/sync narrows to the models an app opened to the browser, so a model left out of its models option is never created.

The client

resolveClient is called only by tooling, never on the request path, so it can be as expensive as it needs to be. It receives the app's resolved config, for an owner whose connection lives there rather than in its own options:

resolveClient: (config) => config.storage?.client,

Return whatever the app configured — Farm detects the shape:

ReturnedResult
pg, mysql2, node:sqlite, or anything with query/preparemigrated
an unstorage mount, or anything with getItem/setItemreported as having no tables
anything elsean error naming your plugin and pointing at its own tooling

Set dialect explicitly when a client exposes a generic query and is not Postgres — the shape alone cannot tell Postgres and MySQL apart.

What is generated

Everything comes from schema metadata, so the SQL is derivable and reviewable before it runs:

DeclarationWhat it emits
name on a model or fieldthe real table or column name
typethe column type for the target dialect
primaryKey: truePRIMARY KEY
unique: trueUNIQUE on the column
index: trueCREATE INDEX "<table>_<column>_idx"
constraints: [...]CREATE [UNIQUE] INDEX "<table>_<columns>_<type>"
defaulta literal, for strings, numbers, and booleans
default: "now" on a datetimeDEFAULT CURRENT_TIMESTAMP
referenceREFERENCES <table> (<column>), with ON DELETE when set

Postgres, SQLite, and MySQL are supported. A reference to a model outside this owner's schema is left as a comment rather than a foreign key, since the other table may not exist yet.

Two things worth knowing

Columns are NOT NULL by default. A field is nullable only when it says so:

updatedAt: { type: "datetime" }                  // NOT NULL
updatedAt: { type: "datetime", nullable: true }  // nullable
updatedAt: { type: "datetime", required: false } // nullable

Names fall back to the schema's own keys, which is exactly what the runtime reads, so the tables this creates are the tables your queries find. A model tasks with a field listId becomes tasks("listId"), not tasks("list_id"). Set name on a model or field to point at a different one.