pithy migrate

Run every Worker’s migration registry against one environment’s D1, or step the latest migration back.

Synopsis

pithy migrate [--env <env>] [--worker <name>] [--rollback] [--json]

Flags

FlagMeaning
--env <env>Target environment: dev, staging, prod, or one you declared. Default dev
--worker <name>Migrate one Worker instead of every Worker under apps/
--rollbackStep the latest applied migration back instead of running forward. Default false
--jsonMachine-readable output. Default false

What it does

The registry, the ordering and the per-database runs are identical everywhere. Only the driver differs: dev runs locally through Miniflare against .wrangler/state — the same store wrangler dev reads — while staging, prod and any custom environment execute over the D1 REST API against the database that environment’s wrangler.jsonc stanza names. You pass no ids.

It fans out over Workers, and a shared database migrates once. Each Worker contributes its own capabilities’ migrations. Workers whose bindings resolve to the same physical D1 are grouped, their sets merged into one ordered provider, and that provider runs a single time — then each result is credited back to the Worker whose capability declared it, so the report never claims a migration a Worker does not own.

--worker narrows what is reported and which databases are visited. It never narrows the registry a visited database runs, because a shared D1’s ledger holds both Workers’ migrations and a partial provider reads as corrupted state.

Every run is idempotent. A second run with nothing pending is a no-op and prints Nothing to migrate.

A database has to exist before it can be migrated, which is pithy provision’s job — and provisioning ends by running this command, so the two are usually one act.

The database has an owner. Every database in the run is claimed for this project — a row beside the migration ledger — before any of them is written to, and a database another project owns aborts the whole run rather than being discovered halfway through. An unstamped database is adopted on first migrate; your own is a no-op.

This is why migrate needs name in the root pithy.config.ts and refuses to guess one. A guessed name would stamp one value and check a different one next run, locking a project out of its own database.

The ledger has to match the declaration, in both directions. Before anything is written, every database in the run is read back. A migration this project declares and the database has not applied is what the run applies. A migration the database has applied that nothing declares any more is refused by name.

That second state is what deleting a migration file leaves behind, and Kysely treats it as a corrupted chain — so nothing can migrate until the two agree. No migration is broken there, so the remedy is not fix the migration: on dev it is to delete .wrangler/state and run again, and on a deployed environment it is to restore the migration or remove its pithy_migrations row, because that database has real rows in it. pithy doctor reports the same state before you reach for migrate.

Migrate never seeds and never deploys. It moves the schema your capabilities and your app declare. Data fixtures are pithy seed; shipping code is pithy deploy, which warns when the target environment’s schema is behind but never migrates for you.

Credentials for a remote run are CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, from <config>/cloudflare.json locally — or straight from the environment in CI, which has no config file. A dev run needs neither.

--json

One line on stdout, whose workers array groups the run exactly as the human output does. A failure is one {"error": …} line on stderr and a non-zero exit.

KeyTypeMeaning
commandstring"migrate"
projectstringThe project this run was claimed as — name from the root pithy.config.ts
envstringThe environment migrated
rollbackbooleanWhether the run stepped back rather than forward. Mirrors --rollback
workersobject[]One entry per Worker in the fan-out, in report order. A Worker with no migrations still appears
workers[].workerstringThe Worker’s name
workers[].databasesobject[]The databases that Worker’s registry touched, in registry order. Empty when it composes no migrations
workers[].databases[].databasestringThe database name — a capability’s databases key
workers[].databases[].bindingstringThe D1 binding it resolves to in that Worker’s wrangler.jsonc
workers[].databases[].resultsobject[]What the migrator did, credited to this Worker. Empty when nothing moved
…results[].migrationNamestringThe composed migration name, carrying its capability namespace
…results[].directionstring"Up" or "Down"
…results[].statusstring"Success", "Error", or "NotExecuted" — the last meaning an earlier migration failed first
workers[].databases[].sharedWithstring[], optionalThe other Workers bound to this same physical D1. Present only when the database is shared

A run that died partway

A fan-out has no transaction across databases: the third one throws and the first two are already ahead of it. So the failure line still goes to stderr and the exit is still non-zero, and stdout carries what the run changed on the way — the record you need most when a migration dies mid-fan-out.

$ pithy migrate --env staging --json
{"command":"migrate","project":"acme","env":"staging","rollback":false,"workers":[…],"failed":{"binding":"COLLAB_DB","database":"collab"},"unreached":[{"binding":"MEDIA_DB","database":"media"}],"interrupted":true}
KeyTypeMeaning
interruptedbooleanPresent and true on this line alone. It is what says workers is a truncated report, not a whole one
workersobject[]Here, only the databases whose pass completed before the failure. Same shape as above
failedobjectThe database the run died on, as its binding and database. Its schema is in whatever state the failed pass left it. No reason — what a migration threw is on the {"error": …} line
unreachedobject[]Every database in scope the run never opened, in fan-out order. Empty means the failure was on the last one, never nothing was scanned

Errors

  • No pithy.config.ts here. Run it from a Pithy project.
  • A migration run needs a project name. Set name in the root pithy.config.ts. The stamp is what refuses another project’s database instead of silently merging two schemas.
  • A database another project owns. The refusal names both projects, and nothing in the run has been written. Nothing clears a stamp: handing a database to another project deliberately means dropping that table by hand.
  • <worker>: wrangler.jsonc has no env.<env> stanza. Add the environment and its D1 bindings. A Worker outside the run’s scope with no stanza for that environment is skipped instead, because it has never migrated there.
  • wrangler.jsonc env.<env> has no database_id for the "<binding>" binding. A remote run needs a real id; there is no local fallback to migrate the wrong store against.
  • Cloudflare credentials are missing. Remote runs only.
  • Two databases on one binding, within a Worker. A wiring mistake — they would migrate against a single physical store. Give each its own binding.
  • Two Workers migrating one binding under one namespace with different migrations. A pair of capabilities wearing one name; their composed keys would collide in the ledger. Rename one, or bind the Workers to different databases.
  • <binding> records <migration>. This project no longer declares it. The ledger holds a migration this project has since dropped, so the migrator refuses the whole chain. The action line says which remedy applies to this database.
  • A migration itself failing. The refusal names the migration, the binding it was running against, and what the runtime actually said. The chain is applied one migration at a time, so detail also carries the migrations applied before it, which stay applied. The failed migration is not among them: each migration’s statements go to D1 as one batch, which is one transaction, so a migration that fails partway applies none of itself and records nothing. Nothing is ever batched across a migration boundary — a partial chain has to stay representable in the ledger.
  • --env is validated at the flag. production is answered with prod before any config loads or any database opens.

Examples

pithy migrate
pithy migrate --env staging --json
pithy migrate --env staging --rollback
pithy migrate --env prod --worker api --json

A shared database, reported from both sides:

{"command":"migrate","project":"acme","env":"prod","rollback":false,"workers":[{"worker":"acme-api","databases":[{"database":"app","binding":"DB","results":[{"migrationName":"auth_0001_init","direction":"Up","status":"Success"}],"sharedWith":["acme-collab"]}]},{"worker":"acme-collab","databases":[{"database":"app","binding":"DB","results":[],"sharedWith":["acme-api"]}]}]}

Nothing in that payload is a credential.

ESC