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> migrateUsing 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:
| Flag | What it does |
|---|---|
| (none) | print the statements |
--write <file> | save them to a file to commit alongside your other migrations |
--apply | execute 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 createand 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:
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.
| Field | Purpose |
|---|---|
name | the <plugin> in farm <plugin> migrate |
schema | the schema whose models this plugin stores |
models | model keys it owns; defaults to every model in the schema |
resolveClient | returns the configured connection, or the storage mount |
dialect | only 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:
| Returned | Result |
|---|---|
pg, mysql2, node:sqlite, or anything with query/prepare | migrated |
an unstorage mount, or anything with getItem/setItem | reported as having no tables |
| anything else | an 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:
| Declaration | What it emits |
|---|---|
name on a model or field | the real table or column name |
type | the column type for the target dialect |
primaryKey: true | PRIMARY KEY |
unique: true | UNIQUE on the column |
index: true | CREATE INDEX "<table>_<column>_idx" |
constraints: [...] | CREATE [UNIQUE] INDEX "<table>_<columns>_<type>" |
default | a literal, for strings, numbers, and booleans |
default: "now" on a datetime | DEFAULT CURRENT_TIMESTAMP |
reference | REFERENCES <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 } // nullableNames 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.
Let a plugin declare the tables its schema needs, so farm <plugin> migrate can create them from the schema alone.