Skip to content
Talk to our solutions team

Migrations

Every table, column and index in a tenant’s database is derived from that tenant’s entity definitions by the backend dialect. Applying that DDL is a separate, explicit operator action: a definition change on disk changes nothing in any database until you run a migration verb.

A service never hand-rolls SQL, a DSN, or a migration. The rule is structural, not a convention:

ConcernWho owns it
DDL textThe backend dialect, from the loaded definition object — CREATE TABLE, CREATE INDEX, ALTER COLUMN, audit/history side tables, CREATE SCHEMA
Connection stringThe host, built from the tenant’s pool config with the password resolved through Vault
MigrationThe plan pipeline — generate, review, apply, ledger

The planner is orchestration only; it never contains a backend-specific string. The only way author-written SQL enters a database is a declared init script or migration script, and both run through the same plan and ledger machinery as generated DDL.

StageWhat it doesProducesTouches the database
DiscoverIntrospects the live database for the target namespace, excluding tables prefixed data_The live schema: tables, columns, indexes, side-table namesReads only
CompareDiffs the composed definitions against the live schema in physical type spaceDiff{Ops, Refusals, Warnings}No
PlanLowers each op to SQL through the dialect and assigns 1-based step orderPlan{PlanID, Source, MaxRisk, Warnings, Refusals, Steps}, persisted as one plan row plus one step row per step, all pendingWrites ledger rows
ApplyRuns steps in order under a datastore lock, transitioning ledger rows as it goesApplyResult{PlanID, Status, StepsApplied, StepsFailed, StepsSkipped, FailedStep, HaltedStep}Yes — the only stage that does
VerifyValidation steps inside the plan run a read query and compare column 1 of row 1 to the step’s expected valueStep pass or failReads only

Plans are deterministic on the declaration side. Entities sort by name, fields by declared position then name, indexes by name, and step order is op order — so the same definitions against the same live state produce the same additive plan, in the same order, every time. Two environments can be compared plan to plan.

Comparison happens in physical type space, not logical. Several logical types map to one physical type, so a purely logical comparison would report a freshly created database as full of drift. The dialect is threaded through the diff so a matched column is compared as “what would CREATE TABLE emit for this field” against the live column’s raw type, and defaults are compared the same way. Generate → apply → generate therefore converges on an empty plan.

ThingAutomaticExplicit
Deriving DDL from definitions
Applying DDLdata bootstrap, or data plan generate + data plan apply
Creating the PostgreSQL schema (namespace)CREATE SCHEMA IF NOT EXISTS, issued by bootstrap before the plan
Creating the physical databaseExternal — IaC or an operator, never the engine
Datastore init.scripts✅ on the first engine build per process, per (tenant, datastore)data init run forces a re-pass
migrations[].scriptsAppended to the plan, applied by plan apply
Seedsdata seed run
Engine rebuild after a definition changeDELETE /cache, a tenant config refresh, or a restart
Foreign keys, CHECK constraints, enum typesNever emitted by the plan pipeline — declare them in an init script
Dropping a table or an indexNever emitted at all; reported as a warning
Dropping a columnOpt in at generate and confirm high risk at apply

Nothing schema-shaped is ever applied at runtime. The engine build path runs datastore init scripts and nothing else; there is no migrate call anywhere on it. That is deliberate — a definition change reaching a running process must not silently reshape a production database.

bootstrap is a separate verb because the plan that creates the plan ledger cannot be recorded in that ledger. It unions the engine’s own system entities into a copy of the composed schema, generates the plan unpersisted, and applies it without taking a lock — neither the ledger table nor the lock table exists yet.

Terminal window
data.svc -f data.yaml data bootstrap --tenant acme:shop:prod:eu1
data.svc -f data.yaml data bootstrap --tenant acme:shop:prod:eu1 --datastore main
data.svc -f data.yaml data bootstrap --tenant acme:shop:prod:eu1 --scope shared
data.svc -f data.yaml data bootstrap --tenant acme:shop:prod:eu1 --dry-run

It is idempotent: every generated statement is IF NOT EXISTS, and a re-run against a current database diffs to zero steps. After the first run, the persisted generate → apply lifecycle takes over.

Six engine-owned tables are created in the tenant’s own namespace alongside the entity tables:

TableHolds
data_plansOne row per plan: id, source, max risk, status, trigger
data_applied_changesThe step-level ledger — one row per plan step or seed application, with status, timing, checksum and error
data_locksCoordination locks, one row per held resource
data_archive_handoffThe retention queue between marking a row for archive and the archive writer confirming it
data_materialized_refresh_tasksThe outbox for cross-entity materialized computed-field refreshes
data_db_versionCreated empty. Nothing in the shipped pipeline writes or reads it

Entities carrying scope: shared or scope: superadmin are served by the scope’s own engine and pool, so their DDL belongs to the deployment rather than to any one tenant. --scope shared and --scope superadmin migrate those planes once per deployment into the scope’s reserved binding and namespace; --tenant still names a tenant of the product whose schema declares them. Scoped entities never appear in a tenant plan.

Terminal window
data.svc -f data.yaml data plan generate --tenant acme:shop:prod:eu1
data.svc -f data.yaml data plan show <plan-id> --tenant acme:shop:prod:eu1 --output json
data.svc -f data.yaml data plan apply <plan-id> --tenant acme:shop:prod:eu1 --confirm-risk medium
data.svc -f data.yaml data applied list --tenant acme:shop:prod:eu1 --plan-id <plan-id>

generate writes a plan row and one row per step, all pending, and executes no DDL. apply rehydrates that plan from the ledger, so generate and apply may happen in different processes, by different people, minutes or days apart. Rehydration does not restore warnings or refusals — a plan only reaches pending when it has none.

Each step carries the exact statement it will run. Read it before applying; --output json prints the full SQL of every step, which the table renderer truncates.

Every op the diff can emit, what triggers it, the steps it lowers to, and its risk tier:

OpEmitted whenStepsRisk
add_tableAn entity has no live tableCREATE TABLE, plus one CREATE INDEX per declared indexlow
add_columnA field has no live column and is nullable: true or not required:ALTER TABLE … ADD COLUMNlow
add_columnThe field is required: and not nullable:, with a defaultALTER TABLE … ADD COLUMNmedium
add_indexA declared index is missingCREATE INDEXlow
add_audit_table / add_audit_indexHistory strategy typeaudit, <entity>_audit missingOne create eachlow
add_history_table / add_history_indexHistory strategy type4, <entity>_history missingOne create eachlow
change_column_defaultThe default drifted from what CREATE TABLE would emitALTER COLUMN … SET/DROP DEFAULTlow
change_column_nullableLive NOT NULL → declared nullableDROP NOT NULLlow
change_column_nullableLive nullable → declared NOT NULLA NULL-count validation expecting 0, then SET NOT NULLmedium
change_column_typePhysical types differ and the change is on the compatible listDialect-emitted ALTER COLUMNmedium
rename_columnAuto-detected 1:1 shape matchALTER TABLE … RENAME COLUMNmedium
drop_columnA live column has no field and drops are opted inAn informational row count, then DROP COLUMNhigh

Indexes over fields marked no-index or encrypted are skipped when the table is created — a plaintext index on ciphertext leaks ordering and equality without being useful.

Risk is low, medium or high. Plan.MaxRisk is the maximum across the plan’s ops, and apply compares it to what the caller confirmed:

ConfirmedApplies
low (the default)Table, column and index additions, side tables, default changes, nullability loosening
mediumAlso type widening, nullability tightening, renames, NOT NULL columns with a default
highAlso column drops

A plan whose risk exceeds the confirmed level is refused outright and no step runs — the run fails before the lock is even taken. The confirmed value is the operator’s acknowledgement, which is why it is a parameter and not a config setting: --confirm-risk on the CLI, confirm_max_risk on the HTTP route.

migrations[].risk in a definition is parsed and stored but never consulted; the gate uses the planner-computed maximum.

A refusal is the diff declining to guess. Three kinds exist:

KindRaised whenFix
data_lossA live column has no matching field and drops are not opted inRestore the field, or re-generate with drops allowed and confirm high risk
needs_explicit_migrationAn incompatible type change, or a required: non-nullable field added with no defaultDeclare an expand-contract migration script, or give the field a default
ambiguous_changeRename detection matched more than one candidateDeclare the rename explicitly in a migration script

Only these type changes apply inline. Everything else — including every narrowing — is refused:

FromTo
stringtext
intbigint
floatdecimal
datedatetime

Rename detection only fires when an entity has both unmatched fields and unmatched live columns, and only pairs them on an exact shape match: same canonical type, same DDL-level nullability, same default presence (not value), same uniqueness. More than one candidate refuses rather than moving data between columns.

ThingBehaviour
Foreign keysNever emitted. references: drives joins and eager loading in the compiler; a database provisioned by bootstrap or plan apply has no declared FK constraints
CHECK constraintsDiscovered, never compared
Enum typesNo CREATE TYPE is emitted by any migration path. A field mapping to a database enum needs an init script
Unknown live tables and indexesReported as warnings. There is no drop-table or drop-index op; warnings never block apply
Audit and history side tablesCreated in one shot when missing, then left alone. They are never column-diffed — their shape is engine-owned
System tablesFiltered out of the live scan by the data_ prefix, and injected into the definition set only during bootstrap
Entities whose materialization is not tableSkipped entirely — no ops, no warnings. Check this first when a table is unexpectedly missing
Purely virtual computed fieldsExcluded from the column diff. Array fields and materialized computed fields are always treated as nullable whatever nullable: says — the dialects skip NOT NULL on both
Step kindSemantics
ddlOne statement, executed directly. No outer transaction
dmlSame, for data statements
validationRuns a read query and compares the first column of the first row to the expected value: 0, any integer, any, or informational (always passes)
raw_scriptAn author-supplied body, split at top-level ;. Runs inside one transaction when the step carries transactional, otherwise each statement runs bare — see the caution below for which paths carry it
manual_gateHalts the run for operator confirmation. Honoured by the executor, but no planner path emits one
seedSkipped by the executor; seed rows are written by the seed runner

Statement splitting understands single quotes with '' escapes, double-quoted identifiers, line and block comments, and dollar-quoting ($$, $tag$). Block comments do not nest. Anything more exotic belongs in one statement per file.

Steps run in order and the run halts on the first failure. That step flips to failed, the plan flips to failed, every later step stays pending, and the error comes back with a partial result. Steps already applied stay applied.

Plan statusMeaning
pendingGenerated, not yet run
runningApply in progress
appliedEvery step succeeded
failedA step failed; earlier steps are applied
haltedStopped at a manual gate
Step statusMeaning
pendingNot reached
runningIn flight
appliedSucceeded
failedRaised an error, or a validation mismatched
skippedFast-forwarded, or suppressed by a dry run

There is no rollback. The executor deliberately runs each step without an outer transaction, because wrapping the plan would roll back steps the ledger has already recorded as applied. Recovery is fix-and-re-run: generated DDL is IF NOT EXISTS, and the ledger records exactly what ran. migrations[].down is parsed and stored, and never executed by anything.

--dry-run on apply runs validation steps and records every DDL, DML and raw-script step as skipped with a dry-run note. It takes no lock.

A migration verb targets exactly one (tenant, datastore) pair. There is no fleet sweep — a deployment iterates tenants itself. Per tenant, the order is fixed:

  1. Provision the physical database and grant the pool’s role rights on it. External.
  2. data bootstrap for that tenant’s datastore, once. It creates the namespace, the entity tables and the ledger.
  3. data plan generate + data plan apply for every later definition change.
  4. The first request for that (tenant, datastore) pair builds the engine and runs the datastore init scripts once.

Scope planes (shared, superadmin) are bootstrapped once for the deployment, not per tenant.

One coarse lock guards each (tenant, datastore), keyed tenant:<key>:datastore:<name>, held as a row in data_locks in the database being changed:

PropertyValue
ScopeOne lock per (tenant, datastore) — migration, init scripts, seeds and retention all contend for it
Default lease5 minutes, extended by a heartbeat at one third of the lease when the lease exceeds 30s
Crash behaviourThe lease expires and the next caller steals the row
RecordedHolder, host, operation kind, plan id, notes, acquired and expiry timestamps
Not taken byDry runs, and the bootstrap apply (the table does not exist yet)

Operation kind is recorded for forensics only — it is not part of the key, so retention cannot run while a migration runs on the same datastore. The init runner treats a held lock as benign: it returns with zero scripts applied and moves on.

Ledger, lock and plan SQL run over the raw database handle, which bypasses the per-checkout search_path a shared-endpoint pool relies on. The tenant’s physical schema — the pool’s schema: property, falling back to the composed schema’s name — has to reach all three of: the dialect (so DDL is emitted qualified), the discovery inputs (so introspection reads the right namespace), and the ledger and lock manager (so their own rows land in the right namespace). Setting only some of them is the classic phantom-diff and wrong-namespace-ledger failure. See Datastores and database pools for how the value is resolved.

migrations[].scripts[] attaches author-written SQL to a version label. The scripts become raw_script steps appended after the generated DDL, in migrations[] order and then declaration order. To run SQL before the generated DDL, split the work across two migration entries.

migrations:
- name: add-order-audit
version: "2026.07.01"
description: "trigger for the orders audit log"
scripts:
- path: sql/triggers/orders_audit.sql
kind: ddl
- path: sql/indexes/orders_concurrent.sql
kind: ddl
transactional: false
KeyTypeDefaultNotes
namestringMigration name
versionstringVersion label. Becomes the entity column of every ledger row for these scripts
descriptionstringCopied to each emitted step’s notes in the generate output. The step ledger has no notes column, so plan show does not replay it
scripts[].pathstringRequired. Resolved at load time against the definition assets; a missing file is a hard load error
scripts[].descriptionstringOperator-facing context
scripts[].kindstringddlddl or dml. Anything else is a load error
scripts[].transactionalbooltrueParsed and carried onto the step, but dropped when plan apply rehydrates from the ledger — see the caution under Steps and execution. Honoured on the datastore init path
up, down, riskParsed and stored; no code path reads them

Script bodies are rendered as templates before execution. {{tenant}} is the tenant key and {{schema}} is the physical namespace to qualify objects with; conditionals and filters work, not just variable substitution.

Datastore-level init.scripts[] use the same object shape but run on the first engine build rather than inside a plan, and are documented with the datastore surface — see Init scripts. Their plan row is recorded with a risk of high, but nothing enforces that: the init runner applies its steps with the default confirmation, so the label is a record, not a gate.

Drift is the read-only question “did someone ALTER out of band, or did a migration fail silently?”. The engine handle exposes Drift(ctx, DriftOptions{…}), which discovers the live database, compares it to the composed definitions, and returns the diff without executing anything.

OptionDefaultEffect
SystemTablePrefixesdata_, _pg_, _information_, pg_, information_Tables excluded from the live scan
IncludeAuditTablesfalseWhether _audit / _history side tables take part
AllowDropsfalseWhether unmatched live columns surface as ops instead of refusals

An empty diff means the live schema matches the definitions. A backend whose provider exposes no discovery implementation returns an error instead; all in-tree backends implement it.

data.svc has no drift verb. The operator equivalent is data plan generate, which performs the same discover-and-compare and executes no DDL — it differs only in that it persists a plan row you can then choose to apply or ignore.

Two independent things have to happen, in this order, and neither implies the other.

  1. Migrate the databasedata plan generate then data plan apply (or data bootstrap on a fresh tenant). Skip this and queries against the new shape fail with “relation does not exist” or “column does not exist”.
  2. Rebuild the engine — the process caches a compiled schema, pool and hook chain per (tenant, datastore). Until it rebuilds, writes still use the old shape: a create succeeds and silently omits the new column. Restart the process, refresh the tenant config, or clear the cache in place.
Terminal window
curl -X DELETE http://127.0.0.1:8099/cache \
-H 'Authorization: Bearer <token>' \
-H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: eu1'

Clearing the cache drops every cached schema, pool, engine and action registry for the tenant and the next request rebuilds from source. It rebuilds the engine; it does not touch the database.

Every verb boots the same configuration file as the server, resolves the tenant, and exits. It never opens the listener.

CommandFlagsDoes
data bootstrap--dry-run, --scope shared|superadminCreate the namespace, entity tables, indexes and ledger on a fresh database
data plan generate--allow-dropsDiscover, diff, plan, persist. No DDL
data plan show <plan-id>Render a persisted plan and its steps
data plan apply <plan-id>--confirm-risk low|medium|high, --dry-runExecute the plan under the datastore lock
data applied list--plan-idRead the step-level ledger
data init listDeclared init scripts and their applied state
data init runForce an init-script pass now; ledger idempotency still applies
data seed run--name, --env, --dry-run, --force, --triggered-byApply declared seeds through the engine’s own write path
data seed listDeclared seeds and their applied state
data locks list--include-expiredShow held coordination locks
data locks release <resource>Force-release a stuck lock

--tenant (required), --datastore (empty selects the schema default) and --output table|json are shared by every verb. data plan generate --source is registered but never read — the source is always the definitions.

The admin surface mounts at /admin/data/* and the cross-tenant surface at /superadmin/*; behind the gateway both sit under /data/. Every route needs a bearer token and the four CPET headers — see the Data API reference for the request conventions.

RouteBodyGate
POST /admin/data/plans{"datastore": "", "allow_drops": false} — both optionalGenerate
GET /admin/data/plans/:id?datastore=Read
POST /admin/data/plans/:id/apply{"datastore": "", "confirm_max_risk": "low", "dry_run": false}Apply
GET /admin/data/applied?plan_id= required, ?datastore= optionalRead
POST /admin/data/seeds/runs{"datastore","names","environment","dry_run","force"} — runs synchronouslySeed
GET /admin/data/seedsRead
GET /admin/data/locks?datastore=, ?prefix=, ?op_kind=, ?include_expired=Read
DELETE /admin/data/locks/:resourceLocks
DELETE /cacheToken only

Generate returns {plan_id, source, max_risk, warnings?, refusals?, steps[]}. Apply returns 200 when the status is applied and 207 Multi-Status otherwise — including when a partial result accompanies an error — with {plan_id, status, steps_applied, steps_failed, steps_skipped, failed_step?, halted_step?, error?}. An invalid confirm_max_risk is a 400. Errors on this surface are a flat {"error": "<message>"}, not the typed envelope the data plane uses.

Request field names are snake_case. A camelCase key is not read: it silently leaves drops disallowed and the confirmed risk at low.

The superadmin mirror repeats the plan, applied, seed and lock routes under /superadmin/tenant/:tenant/…, plus GET /superadmin/tenant to list the tenants this process holds resources for and DELETE /superadmin/tenant/:tenant/cache. :tenant is the bare tenant name — customer, product and environment come from the caller’s own tenancy context, and a value containing the key separator is a 400. A control plane governs exactly its siblings; a cross-product target is unrepresentable.

Each operation class carries its own gate — read, generate, apply, seed, retention, locks, maintenance — so whether a tenant admin may execute DDL is a per-deployment decision. Any gate left unset resolves to “admin”, so a partial policy is never accidentally open, and /superadmin/* is superadmin-only regardless of the policy. The deployable service ships with generate, read and seed at admin, and apply, retention and lock release raised to superadmin.

A host service reaches the pipeline through an admin provider it registers at boot; handler packages never import a host’s boot package. An unregistered host fails with a no-provider-registered error naming the missing boot call, rather than a nil dereference.

Entry pointPurpose
migrate.Generate(ctx, GenerateInputs, GenerateOptions)The whole discover → compare → plan → persist sequence
migrate.Compare(definitions, live, DiffOptions)Diff only. DiffOptions.Dialect is required for physical-type comparison
migrate.PlanFromDiff(diff, PlanOptions)Lower a diff to SQL steps
migrate.RehydratePlan(planRecord, stepRecords)Rebuild an in-memory plan from ledger rows
migrate.NewExecutor(db, ledger).Apply(ctx, plan, ApplyOptions)Execute, with MaxRiskConfirmed, DryRun, HaltOnManualGate
migrate.NewSQLLedger(db).InSchema(ns)The persisted plan and step ledger
migrate.NewSQLLockManager(db).InSchema(ns) + migrate.ResourceKey(tenant, datastore)Coordination locks and their canonical key
migrate.EnsureNamespace(ctx, db, dialect)CREATE SCHEMA IF NOT EXISTS, from the dialect. A no-op on schema-less backends
migrate.SystemEntities()The six engine-owned entities, in emission order — bootstrap only
migrate.NewInitRunner(datastore, executor, ledger).Run(ctx)The init-script pass
migrate.NewSeeder(schema, writer, ledger).Run(ctx, opts)Seeds, written through the engine’s hook chain
migrate.RawScriptBindingsWithSchema(tenant, schema)The {{tenant}} / {{schema}} bindings for raw scripts
Drift(ctx, DriftOptions{…}) on the engine handleRead-only drift comparison
Migrate(ctx) / MigrateDetailed(ctx, onError) on the engine handleThe zero-config sweep described below

GenerateInputs carries the definitions, the discovery implementation, the dialect, the database handle, the ledger, the namespace and the raw-script bindings. GenerateOptions carries the source label, AllowDrops, the trigger labels and Persist.

The engine handle also exposes a blind, non-introspecting migrate: it emits CREATE … IF NOT EXISTS for every entity and reports per-object failures, continuing past each so one pass shows every problem. Emission order is the entity map’s iteration order, so it is not stable between runs. It has no plan, no review, no ledger and no rollback, and it is the only path that emits foreign-key constraints. Nothing in data.svc calls it — it exists for tests and embedded single-tenant use. Production DDL goes through the plan pipeline.