Appearance
TailorDB Migrations (Beta)
Beta: The
tailordb migrationcommand and the migration runtime are beta features. They may introduce breaking changes in future releases. The CLI emits a beta warning on every invocation.
The migration system tracks changes to your TailorDB table definitions over time and applies them to deployed workspaces with optional data transformation scripts.
For the CLI command reference, see tailordb migration. This document covers concepts, workflows, and operational guidance.
Overview
Key Properties
- Local snapshot–based diff detection — each migration is generated by diffing your current table definitions against the previous snapshot stored in
migrations/<NNNN>/. - Transaction-wrapped data migrations — each
migrate.tsscript runs inside a database transaction on the platform; if the script throws, all changes in that migration roll back. - Automatic execution during
apply—tailor deploydetects pending migrations, runs the two-stage table update (pre-migration → script → post-migration), and updates the migration checkpoint label. - Type-safe scripts — the generated
db.tsprovides Kysely types that reflect the schema state before the migration runs, so transformations are written against the actual data shape.
Files in migrations/
migrations/
├── 0000/ # Initial schema snapshot
│ └── schema.json
├── 0001/ # First change
│ ├── diff.json # Field-level diff from 0000
│ ├── migrate.ts # Data migration script (auto-generated for breaking changes; can be added manually via `migration script`)
│ ├── db.ts # Kysely types for the script (pre-migration shape)
│ └── db.pglite.ts # CREATE TABLE script of that shape, for PGlite tests
├── 0002/
│ └── diff.json # No script — non-breaking changes only
└── ...0000 always contains a full snapshot. 0001 and onward contain a diff plus, optionally, a script and its types (auto-generated for breaking changes, or added manually via tailordb migration script for warning-tier changes). Commit the entire migrations/ directory to version control.
Initial Setup
New project
When you start with no migrations/ directory:
- Add the
migrationblock totailor.config.ts(see Configuration). - Define your initial tables in
tailordb/. - Generate the initial migration:bashThis creates
tailor tailordb migration generatemigrations/0000/schema.jsonfrom your current tables. - Run
tailor deploy. The migration label is set to0000on the deployed namespace.
Adding migrations to an existing project
If you already have a deployed workspace whose schema matches your local table definitions:
- Add the
migrationblock totailor.config.ts. - Run
tailor tailordb migration generateto create0000/schema.jsonfrom current local tables. - Run
tailor deploy. It first checks that the deployed tables match0000. Because they do, no script runs; only the migration label is set.
Until a deploy sets the migration label, tailor deploy and tailor tailordb migration validate compare the deployed tables with 0000. When they differ, tailor deploy stops with remote schema drift and migration validate fails, for example when a table was edited in the console, or when a local change had not been deployed before you generated 0000. Run tailor tailordb migration validate before the first deploy to check, and reconcile the difference before deploying:
- To keep the deployed tables, update your local tables to match them, then recreate
0000withtailor tailordb migration generate --init.--initdeletes every migration of the namespace, so do this only while no workspace has been deployed with these migrations. - To replace the deployed tables with
0000, runtailor tailordb migration sync 0000. Tables and fields that0000does not have are deleted together with their data.
If an earlier deploy stopped after creating only some of the tables, the next deploy reports the missing tables as drift; tailor tailordb migration sync 0000 creates them.
Resetting
tailor tailordb migration generate --init deletes the existing migrations/ directory of every namespace with migrations configured and creates 0000 from the current local tables. Add --namespace <name> to reset only that namespace and leave the others untouched. Use it only before the project is deployed. For a deployed migration history, use migration rebaseline, which verifies the history and connected workspace before replacing any files.
Declining the --init confirmation leaves existing migrations in place and fails with MIGRATION_GENERATE_CANCELLED.
Migration Workflow
A typical change cycle:
Modify a table definition.
typescript// tailordb/user.ts export const user = db.table("User", { name: db.string(), email: db.string(), // ← new required field ...db.fields.timestamps(), });Generate the migration.
bashtailor tailordb migration generate --name "add email to user"Output:
Generated migration 0001 Diff file: ./migrations/0001/diff.json Migration script: ./migrations/0001/migrate.ts DB types: ./migrations/0001/db.tsIf
EDITORorVISUALis set,migrate.tsopens automatically when the command runs in an interactive terminal outside CI without JSON output.tailordb migration scriptopens the file it scaffolds the same way.Edit
migrate.tsto populate data for the new required field:typescriptimport type { MigrationSteps, Transaction } from "./db"; async function populateUserEmail(trx: Transaction): Promise<void> { await trx .updateTable("User") .set({ email: "default@example.com" }) .where("email", "is", null) .execute(); } export const steps = { populateUserEmail: { run: populateUserEmail }, } satisfies MigrationSteps;Apply.
bashtailor deployThe pre-migration phase relaxes the new field to optional, the script runs and populates values, then the post-migration phase enforces
required: true.
When you decline a namespace's breaking-change confirmation, migration generate skips that namespace, continues generating migrations for the others, and exits successfully with a Skipped: <namespaces> summary. Declining every namespace also succeeds without writing migrations. Check the summary before deploying if you intended to generate every namespace.
JSON output includes skippedNamespaces alongside migrations. It is always an empty array in actual --json runs because JSON mode does not prompt. Programmatic callers of migrateGenerate() receive a MigrateGenerateResult and can inspect skippedNamespaces before continuing.
Warnings and optional migration scripts
Some non-breaking changes can still cause data loss — most notably removing a field (field_removed), removing a table (table_removed), or removing a member inside a nested field (reported on the nested field's field_modified change). migration generate reports these as warnings:
Warning: data loss possible:
- User.legacyParentId: Field removed (existing data will no longer be accessible through the schema after the post-migration phase)No migrate.ts is generated automatically because the schema change itself is non-breaking, but the existing data is no longer accessible through the active schema after the post-migration phase. The platform may retain a removed field's underlying stored value, so do not rely on removal to clear data before reusing the same field name. If you need to preserve, transform, or clear that data first, add a script with:
bash
tailor tailordb migration script 0002This writes migrations/0002/migrate.ts, migrations/0002/db.ts, and migrations/0002/db.pglite.ts next to the existing diff.json (add --with-test to also scaffold the tests — see Testing Migrations Locally). The removed field stays readable inside migrate.ts because the pre-migration phase keeps it on the table until the script finishes (see Per-migration phases). The next tailor deploy runs the script automatically — migrate.ts is executed whenever the file exists on disk, regardless of whether the diff itself required it.
If the data loss is intentional and no script is needed, record that decision the same way as for breaking changes (see Breaking changes without a script):
bash
tailor tailordb migration script 0002 --no-script --reason "column no longer needed, data can be dropped"In an interactive session, migration generate offers to record the reason on the spot when it detects warnings. The acknowledgment is stored in diff.json, so it is reviewable in the PR, and it satisfies migration validate --strict — useful for enforcing in CI that destructive changes are explicitly acknowledged before merge (see Schema verification).
Renaming a field
Renaming a field in a table definition looks like a removal plus an addition to the diff engine. Left as-is, that combination silently drops the old field's data: the removal is only a warning, so nothing forces a data copy.
To prevent that, when migration generate finds a removed field and an added field in the same table whose stored values can be copied without changing their meaning, it asks whether the change is a rename. Serial fields are never rename candidates, and an enum field only qualifies when it keeps every value of the removed field:
? User.fullName was removed and displayName was added with a compatible type. Was it renamed to displayName? (Y/n)In non-interactive environments (or with --yes), no prompt is shown and the command fails while a rename candidate is left unresolved — writing it as remove + add would silently drop the field's data at deploy. Resolve every candidate explicitly: pass the rename, or confirm a genuine removal with --drop:
bash
tailor tailordb migration generate --rename "User.fullName:displayName"
tailor tailordb migration generate --drop "User.fullName"Repeat --rename and --drop for multiple fields. A --rename that does not match a compatible removed + added pair, or a --drop that does not match a removed field, fails with an error.
A confirmed rename is recorded as a single field_renamed change and treated as breaking, so a migration script is required. The generated migrate.ts copies the old field into the new one with a single set-based update, and the generated db.ts exposes both the old field (readable) and the new field (writable). The copy intentionally overwrites every row without checking for existing values: values of removed fields are retained in storage, so a stale value could otherwise resurface under the new name later. If the new field adds a unique constraint, the script also includes a duplicate-resolution block to run before the constraint is enforced.
During deploy, the pre-migration phase keeps the old field and adds the new field with its constraints relaxed, the script copies the data, and the post-migration phase drops the old field and enforces the new field's constraints — all within a single tailor deploy.
If you decline the prompt (or confirm the removal with --drop), the change stays a plain removal + addition with the usual data-loss warning.
Changing nested member constraints
Adding a required member, making an existing member required, or removing enum values inside a nested field requires a migration script, including inside arrays of objects. During Pre-migration, added or newly required members are optional and removed enum values remain allowed. Post-migration enforces the target constraints after your script fills missing values or replaces removed enum values. A newly added optional object may keep required children: those children are required only when the object is present.
The generated script includes a TODO for each nested constraint change. Replace these with updates to the containing nested field, preserving its other members and handling objects inside arrays. A confirmed member rename generates its copy step alongside these TODOs.
Changing a nested member's type, array-ness, decimal scale, or foreign key target is unsupported in a single migration. Use separate migrations to add a member with the target shape, copy or transform values in a custom script, and then remove the old member.
Adding a relation to an existing nested member is non-breaking, and its existing values are not checked. Fix values that point at no record before you deploy the migration that adds it: afterwards, updating a row that still holds one fails, even when the update changes only other values.
Renaming a member inside a nested field
Members inside a nested field (db.object(...)) are detected the same way: when migration generate finds a member removed from a nested field and a compatible member added under the same parent, it asks whether the member was renamed. Two members qualify only when copying the value preserves it exactly, because a rename copies existing values without transforming them: the type, array-ness, requiredness, foreign key target, decimal scale, hooks, and validations must match (index, unique, and vector may differ, as for a top-level rename), enum values may be added but not removed, an object-typed member must keep the same members recursively, and serial members never qualify.
? User.address.zip was removed and zipCode was added with a compatible type. Was it renamed to zipCode? (Y/n)In non-interactive environments the command fails while a candidate is left unresolved, exactly like field renames. Resolve it with the nested member forms of the same flags (a value with two or more dots before the : targets a member; deeper members use their dotted path, and the new name is a single segment under the same parent):
bash
tailor tailordb migration generate --rename "User.address.zip:zipCode"
tailor tailordb migration generate --rename "User.address.geo.lat:latitude"
tailor tailordb migration generate --drop "User.address.zip"A confirmed rename is recorded on the nested field's field_modified change as memberRenames and treated as breaking, so a migration script is required. The generated migrate.ts reads every row's nested value, stores each renamed member under its new name (descending into arrays at every level), and writes the value back; the old member is kept in the written value because it stays on the schema until the post-migration phase drops it. During deploy, the pre-migration phase keeps the old member on the nested field and adds the new member as optional, the script copies the values, and the post-migration phase drops the old member and enforces the new member's requiredness.
A member removed without a confirmed rename stays a data-loss warning, which migration validate --strict picks up like any other warning; when a compatible sibling was added, the warning names it and the --rename value that confirms the rename:
Warning: data loss possible:
- User.address.zip: Nested member removed (existing values will no longer be accessible through the schema). Possibly renamed to zipCode: confirm it with --rename "User.address.zip:<newName>" to scaffold a copy script, or keep the removal and copy the values yourselfThe pre-migration phase keeps a removed member on the nested field until the script finishes, exactly like a removed top-level field, so a custom script can still read it. Nested fields reach the script as objects. Renaming a nested member requires every ancestor, including the top-level nested field, to keep its nested type and array-ness. It cannot be combined in one migration with renaming its table or the nested field itself, and an object-typed member cannot be renamed in the same migration as one of its own members; split such changes into separate migrations (rename the object first, then its member).
Renaming a table
Renaming a whole table is detected the same way: when migration generate finds a removed table and an added table with a matching shape, it asks whether the change is a rename:
? User was removed and Person was added with a compatible schema. Was it renamed to Person? (Y/n)In non-interactive environments the command fails while a candidate is left unresolved, exactly like field renames. Resolve it with the table forms of the same flags (a value without a . targets a table):
bash
tailor tailordb migration generate --rename "User:Person"
tailor tailordb migration generate --drop "User"Two tables qualify as a rename pair only when copying every row preserves the data: every field must keep its name, type, array-ness, required/unique constraints, foreign key target, and decimal scale; enum fields may gain values but not lose them; indexes must match. A self-referential foreign key, including one on a field inside a nested field, is compared against the new table name and must be optional. A table is also not a candidate when a self-referential foreign key points at another column of the table that is itself a self-referential foreign key, or when a nested field holding a self-referential foreign key is covered by a unique constraint. Tables with serial fields (their values cannot be written by a script) or file fields (file contents are not copied) are never candidates. Name-derived and data-independent settings — pluralForm, description, table settings, permissions, hooks, and validations — may differ.
A confirmed rename is recorded as a single table_renamed change and treated as breaking for two reasons: existing records must be copied by the migration script, and the table's GraphQL API names (derived from the table name and pluralForm) change, which breaks API clients. The generated migrate.ts copies every row from the old table into the new one in id-ordered batches, preserving ids so stored foreign key references stay valid, and the generated db.ts exposes both the old table (readable) and the new table (writable). Self-referential foreign keys, including those on fields inside nested fields, are inserted as null and backfilled after every row exists, so a reference to a row in a later batch cannot fail the copy.
Two caveats apply to the copy. The old table is not write-protected: rows written to it after the script's transaction commits — and before post-migration cleanup drops it — are not carried over, so pause writers to the renamed table for the duration of the deploy. And platform-managed record metadata (creation/update timestamps and actors) cannot be written by the script, so the new table's records carry the migration run's metadata instead of the original values.
Fields on other tables that reference the renamed table via foreignKeyType must be retargeted at the new name in the same change. That retarget is recognized as part of the rename: it is not flagged as a breaking foreign-key change and needs no reference fixup, because record ids are preserved by the copy.
During deploy, the pre-migration phase creates the new table with its full constraints while the old table stays on the namespace, the script copies the rows, and the old table is dropped in post-migration cleanup after the checkpoint advances — all within a single tailor deploy.
Breaking changes without a script
Breaking changes require migrate.ts. If it is missing at deploy time (for example, the generated script was deleted), tailor deploy fails before applying the migration or anything after it. When there is genuinely nothing to migrate — say, the affected table holds no data yet — record an explicit acknowledgment instead of keeping an empty script:
bash
tailor tailordb migration script 0002 --no-script --reason "no data yet, safe to skip"This stores the reason in migrations/0002/diff.json (commit the change). The next tailor deploy applies the schema change as usual, skips only the script step, and logs the recorded reason. The command refuses to record a skip while migrate.ts exists — delete the script first. If migrate.ts is added back later, tailor deploy fails rather than choosing between the script and the acknowledgment; run tailor tailordb migration script 0002 again to clear the now-stale acknowledgment from diff.json (the script then runs on the next deploy), or delete migrate.ts to keep the skip.
Data-only migrations
Sometimes existing data must be transformed without any schema change — fixing values written by an application bug, or a one-off normalization. Create a migration that carries no schema diff and exists only to run its script:
bash
tailor tailordb migration generate --data-only --name "normalize legacy phone numbers"This writes a numbered migration with an empty diff.json, a migrate.ts skeleton (a single migrate step), and db.ts typed against the current schema. Edit migrate.ts to implement the transformation; the next tailor deploy runs it like any other migration script — in a single transaction, advancing the migration checkpoint (see Performance and Large Tables for batching patterns). Because the entry is part of the migration history, the fix is versioned, ordered relative to schema changes, and applied once per workspace.
The command requires a clean state: if the namespace has schema changes that are not yet in migration files, generate the schema migration first. With multiple namespaces, pass --namespace to name the target. --data-only cannot be combined with --init, --rename, --drop, or --expand-contract.
A data-only migration runs in every workspace the history is applied to, including freshly created ones. Write the script so it is safe against tables with no matching rows (a set-based UPDATE with a WHERE clause is naturally a no-op on an empty table). For a fix that should run in a single environment only, or that is too large for one transaction, run it outside the migration history instead, for example as a one-off script scaffolded with tailor function script and executed against a single workspace with tailor function run.
Configuration
typescript
// tailor.config.ts
export default defineConfig({
name: "my-app",
db: {
tailordb: {
files: ["./tailordb/*.ts"],
migration: {
directory: "./migrations",
// Optional. Defaults to the first machine user in auth.machineUsers,
// and must name one of them.
machineUser: "admin-machine-user",
},
},
},
});| Option | Type | Description |
|---|---|---|
migration.directory | string | Directory path for migration files. Required when migrations are enabled for the namespace. Each namespace needs its own directory. |
migration.machineUser | string | Machine user used to run migration scripts. Must be defined in auth.machineUsers. Optional; defaults to the first entry there. |
Generated Files
| File | When generated | Description |
|---|---|---|
0000/schema.json | First migration generate | Full snapshot of all tables in the namespace. |
XXXX/diff.json | Every subsequent migration | Field-level diff against the previous snapshot. |
XXXX/migrate.ts | Auto-generated for breaking changes and --data-only migrations; added manually via tailordb migration script for warning-tier changes | Data transformation script. The main export receives a Kysely Transaction; a steps export runs each step in its own transaction. |
XXXX/db.ts | Generated once when migrate.ts is created | Kysely types reflecting the schema before this migration. Exports Database, Transaction, and MigrationContext. |
XXXX/db.pglite.ts | Generated with db.ts | CREATE TABLE script of the same schema, for running migrate.ts on PGlite. Never deployed. |
XXXX/migrate.test.ts | Added via tailordb migration script --with-test | Unit-test scaffold for migrate.ts (see Testing Migrations Locally). Never deployed. |
XXXX/migrate.pglite.test.ts | Added via tailordb migration script --with-test when @electric-sql/pglite is installed | PGlite test scaffold for migrate.ts. Never deployed. |
db.ts reflects the pre-migration schema because the script runs after the pre-migration phase has temporarily relaxed breaking constraints (e.g., a new required field is added as optional first), so the data being read still matches the previous shape.
Migration file format compatibility
Migration files are versioned independently of the SDK package. This SDK writes format version 8 and reads versions 1 through 8. It normalizes supported older formats in memory; it never rewrites applied migration files on disk. Format version 6 records renames of members inside nested fields (memberRenames); older SDK versions refuse to read it rather than deploying such a migration without the copy step. Format version 7 records whether a migration's db.ts uses Temporal column types (temporal); older SDK versions refuse to read it rather than running the script with Date values. Format version 8 records the defaultDateRepresentation a migration script was generated under (dateRepresentation); older SDK versions refuse to read it rather than running the script with string values.
Supported histories also preserve the behavior of field hooks and validators saved by older SDKs, including access to the record and boolean validators with a separate error message. Legacy update hooks retain existing values for omitted fields; explicitly supplied values, including null, take precedence. This applies to both snapshots and diffs, including nested fields. Your existing migration files can remain as generated.
If a future SDK can no longer replay an old migration format, re-baseline while using an SDK version that still supports the complete history, commit the new baseline, deploy it to every environment, and then upgrade the SDK. A file from a newer unsupported format instead requires upgrading the SDK first. The CLI rejects both cases with guidance rather than attempting a best-effort replay.
The SDK used for this transition must read the old history and write a baseline format that the target SDK accepts. Keep that SDK version pinned until every environment has adopted the new baseline. File-format support does not guarantee compatibility for arbitrary imports in a custom migrate.ts; keep its dependencies pinned and test customized scripts when upgrading.
There is no migration-file conversion command. Keeping applied files unchanged preserves the record of what ran, while migration rebaseline provides the escape hatch when the supported replay window changes.
Migration Script Anatomy
migration generate and migration script scaffold migrate.ts as steps, with one step for each schema change that needs a data migration (see Splitting a migration into steps). The examples in this section use main, the single-transaction form, which still works. A steps script receives the same transaction and MigrationContext in each step's run.
typescript
import type { Transaction } from "./db";
export async function main(trx: Transaction): Promise<void> {
// SELECT is supported.
const users = await trx.selectFrom("User").select(["id", "name"]).execute();
// Loop and transform.
for (const u of users) {
await trx
.updateTable("User")
.set({ displayName: u.name.toUpperCase() })
.where("id", "=", u.id)
.execute();
}
}Worked example: backfilling a new required enum field
Adding a required field is a breaking change, so migration generate scaffolds migrate.ts. Given this table change:
typescript
// tailordb/user.ts
export const user = db.table("User", {
name: db.string(),
email: db.string(),
role: db.enum(["MANAGER", "STAFF"]), // ← new required field
...db.fields.timestamps(),
});existing User rows have no role value yet, so the script assigns one before the post-migration phase enforces the constraint:
typescript
import type { Transaction } from "./db";
export async function main(trx: Transaction): Promise<void> {
await trx.updateTable("User").set({ role: "MANAGER" }).where("role", "is", null).execute();
}The where("role", "is", null) guard keeps the script idempotent — rows that already have a value are untouched if the script re-runs.
Reference scripts for other breaking-change patterns live in the repository's migration fixture templates: backfilling fields that become required and migrating rows off a removed enum value (0005), and de-duplicating values before a unique constraint is added (0006). They show the shape of each migration, not drop-in logic — adapt them to your data (the suffix strategy in 0006, for example, assumes the suffixed names are not already taken).
Accessing environment variables
The migration main receives an optional second argument exposing the variables defined in defineConfig({ env }) — the same values available via context.env in resolvers. The MigrationContext type is exported from the generated ./db:
typescript
import type { Transaction, MigrationContext } from "./db";
export async function main(trx: Transaction, { env }: MigrationContext): Promise<void> {
// Branch on environment-specific config resolved at deploy time
if (env.SKIP_BACKFILL) return;
await trx.updateTable("User").set({ stage: env.ENVIRONMENT }).execute();
}The env values are injected at bundle time (the same mechanism as resolvers/executors/workflow jobs); process.env and other Node-side environment access remain unavailable at runtime. The second argument is optional — existing main(trx) scripts continue to work unchanged.
Rules
- Always use the
trxargument for database access. Anything that bypassestrxis not part of the transaction and will not roll back on failure. - Do not import resolvers, executors, or other SDK runtime services. The migration script runs as a standalone bundle on the platform with only Kysely access; SDK service helpers are not available.
- Standard Node-compatible packages are bundled. Keep dependencies minimal — every import is shipped to the platform.
console.log/console.erroroutput is captured and surfaced underLogs:in the apply output. Use it sparingly for progress markers on long-running migrations.- The script is idempotency-friendly by default because it runs inside a transaction, but plan for re-execution: if a later migration in the same
applyfails, the platform may retry the apply, and you may want your script to tolerate already-migrated rows (e.g.,where("email", "is", null)instead of unconditional updates).
Splitting a migration into steps
A script that exports main runs as one job in one transaction. When the work is too large for one job, or splits naturally into independent parts, export steps instead. Each step runs as its own job in its own transaction, so each step gets the job's execution-time limit to itself and commits on its own.
typescript
import type { MigrationSteps } from "./db";
export const steps = {
backfillCustomer: {
run: async (trx) => {
await trx
.updateTable("Customer")
.set((eb) => ({ updatedAt: eb.ref("createdAt") }))
.where("updatedAt", "is", null)
.execute();
},
},
backfillInvoice: {
run: async (trx) => {
await trx
.updateTable("Invoice")
.set((eb) => ({ updatedAt: eb.ref("createdAt") }))
.where("updatedAt", "is", null)
.execute();
},
},
recomputeTotals: {
dependsOn: ["backfillInvoice"],
run: async (trx, { env }) => {
// Runs only after backfillInvoice has completed.
},
},
} satisfies MigrationSteps;- A script exports either
mainorsteps. If it exports both,mainruns as before andstepsis ignored with a warning fromtailor deployandtailor tailordb migration validate. - Generated scripts export
steps. Each schema change that needs a data migration gets its own step, named for what it does:populate<Table><Field>for an added required field (for examplepopulateUserEmail),rename<Table><Field>for a renamed field,copy<Old>To<New>for a renamed table,update<Table><Field>for a changed field,resolve<Table><Index>for a unique index, andconvert<Table><Field>for a field type conversion. When that name is not a valid step name (for example because it is longer than 64 characters), or when another step or a helper in the script already uses it, the step is namedchange<N>instead (expand<N>for a conversion through a temporary field), where N is the change's position in the migration; the fallback does not mean the schema-derived name was wrong. A step lists independsOnevery earlier step that touches the same field (a foreign key that is added or changes its target also touches the key it points to,idunless the relation sets another one), so steps that touch the same field run in the order the changes are listed and changes to different fields do not wait for each other. The copy of a renamed table's rows runs after the copies of the renamed tables its foreign keys point to, so the rows they reference already exist. It skips the rows whose ids the new table already has, so running it again does not insert them twice. A step that rewrites values that already exist (the duplicate resolution of a unique index or field, a field change, a conversion through a temporary field) has a comment above its function saying so: once it commits it cannot be undone, so check the values it writes before you deploy. Where the scaffold cannot pick a value for you (what an added required field holds in existing records, the new value of duplicates, the value that replaces a removed enum value, the new reference of a retargeted foreign key or of a value that points at no record when a field gains a relation, the copy of renamed tables that reference each other), it callsTODO(message), which./dbexports. The migration fails at that call, andtailordb migration validateandtailor deployreject amigrate.tsthat still calls it before anything is changed. Replace each call with the value, then remove theTODOimport. A--data-onlymigration, and a migration with no data statements to scaffold, get a singlemigratestep; split it into several steps where the work divides. - Each step is an object with a
runfunction — it receives its own transaction and the sameMigrationContextasmain— and an optionaldependsOnlist naming the steps that must complete before it starts. - Steps with no dependency between them have no guaranteed order. Declaration order is not execution order, and a future SDK version may run independent steps concurrently, so declare
dependsOnfor every ordering your steps rely on. - Declare
stepsdirectly asexport const steps = { ... }, with each step written inside it as an object literal with a literal name anddependsOnlist. Step names start with a letter and contain only letters, digits, and underscores, up to 64 characters.tailor tailordb migration validateandtailor deployreject unknown dependencies and cycles before anything is changed. - Each step runs as a separate job, so steps cannot hand data to each other through module-level variables; read what a step needs from the database.
MigrationStepsis declared bydb.tsfiles generated by this SDK version. With an olderdb.ts, annotate eachrunparameter withTransactioninstead.
A script with a single step runs like a main script: one job and one transaction, and a failure inside the transaction rolls its data changes back. The migration is recorded as in progress from the start of the script until its checkpoint is committed or its failure is rolled back, so that a deploy that loses track of the run leaves it for you to settle rather than running it again (see Failure Recovery). If the post-migration schema changes fail after the transaction committed, the schema is restored to its state before the migration but the data changes stay, so the next deploy runs the step again and the step must tolerate rows it already migrated. The rules below apply once the script has two or more steps. A migration that an earlier deploy already left in progress keeps running step by step until it completes, even if the script has since been reduced to one step.
Because steps commit separately, a failure does not undo the steps that already completed:
- If the first step fails before any other step has completed, the migration fails like a
mainscript: the pre-migration schema changes are rolled back. - If a step fails after another step has completed,
tailor deploystops and leaves the migration in progress. The pre-migration schema stays in place and the checkpoint is not advanced. With maintenance mode on, the namespace's tables stay in it, as while the migration runs: GraphQL create, update, and delete operations, bulk upsert, and record events remain disabled, whilereadkeeps its setting.tailor tailordb migration statusreports the migration as in progress. - If
tailor deploycannot confirm whether the steps started, for example because the connection dropped while starting them, the migration also stays in progress. Runtailor deployagain; it checks whether the steps ran before continuing. - Fix the failing step and run
tailor deployagain. The deploy resumes the migration: completed steps do not run again, and the failed step and the steps after it run with the updated script. A completed step is treated like an applied migration — editing it afterwards does not run it again. - If the steps changed since the earlier run, or that run is no longer available, every step runs again from the beginning. See Editing steps of a migration in progress.
- If every step completed but the post-migration schema changes fail (for example, a field the migration makes required still has empty values), the migration also stays in progress with its pre-migration schema, and the next deploy retries those schema changes without running the steps again.
- If the deploy stops during or after the post-migration schema changes but before it records the migration as applied, for example because the process was killed or recording the checkpoint failed, the migration also stays in progress, with each table in either its pre-migration or its post-migration schema. The next deploy resumes it: it applies the pre-migration schema again, so a field the migration makes required is optional until the post-migration schema changes are applied again. Completed steps do not run again unless you add, remove, or rename a step, change the order the steps run in, or the earlier run is no longer available (see Editing steps of a migration in progress).
- To run a completed step again (because it had a bug, or to fix rows the post-migration schema changes reject), change the steps: rename that step and update the
dependsOnlists that name it, or add a step that fixes the rows. Every step then runs again from the beginning, so this relies on every step being safe to run again; to keep the other completed steps from running again, skip them as described below. - Finish a multi-step migration left in progress with
tailor deploy, or settle it withmigration resolve. Until then, the commands listed in Settling a migration that stays in progress refuse to run.
Editing steps of a migration in progress
When tailor deploy finds a migration in progress, what you changed in migrate.ts decides how it continues:
| What you changed since the earlier deploy | What the next tailor deploy does |
|---|---|
| The code of a step that has not completed (the failed step or a later one) | Resumes: completed steps do not run again, and the rest run with the updated code. |
| The code of a step that already completed | Resumes, but the change takes no effect: that step does not run again. |
| A step added, removed, or renamed, or the order the steps run in | Runs every step again from the beginning, and warns about it first. The warning lists the steps that had already succeeded. |
| Nothing, but the earlier run was canceled or is no longer available | Runs every step again from the beginning, and warns about it first. |
Running every step again runs completed steps a second time, which breaks a step that is not safe to run twice. To keep specific steps from running again, pass them to --migration-skip-steps as comma-separated <namespace>/<step> entries:
bash
tailor deploy --migration-skip-steps main-db/backfillUser,main-db/backfillInvoice- A skipped step is reported as completed and is not run. Only steps that succeeded in the migration's earlier run can be skipped, so a step you renamed cannot be.
- The option applies to the migration an earlier deploy left in progress. Deploy fails before changing anything when an entry names a step that did not succeed, a step the migration does not define, a namespace without a migration in progress, or a namespace that no deployed config defines. It also fails when the earlier run's step results cannot be read, because then it cannot confirm which steps succeeded; deploy again to retry.
- It takes effect for the deploy you pass it to. If that run fails, the next
tailor deployresumes it as usual, and the skipped steps stay completed without passing the option again. - To check the entries before deploying, pass the same option to
tailor tailordb migration validate. It reports the entries that deploy would reject for the namespaces of the config it validates.
Write every step so that running it again is safe: besides the cases above, a step can run a second time when a deploy is interrupted right after it commits. Use where clauses that skip rows a step already migrated, as in the example. Rows written while a migration is in progress are not seen by steps that already completed. Maintenance mode blocks GraphQL writes but not the writes your resolvers, executors, or workflows make through Kysely, and without it GraphQL clients can write too, so stop such writers until the migration finishes or write steps that tolerate them.
Rolling back instead of resuming
By default a failure after another step has completed leaves the migration in progress and the namespace's tables in maintenance mode until you fix the step and deploy again. When you would rather restore the tables first and fix the script afterwards, export onFailure next to steps:
typescript
export const onFailure = "rollback";
export const steps = {
// ...
} satisfies MigrationSteps;onFailureis"resume"(the default, described above) or"rollback". Write it asexport const onFailure = "rollback"with one of those two string literals;tailor tailordb migration validateandtailor deployreject anything else. A script that runsmainignores it.- With
"rollback", a failed step after another step has completed, and post-migration schema changes that fail after every step completed, roll back the pre-migration schema changes like a failingmainscript: tables that already existed are restored to their previous shape and GraphQL permissions, and tables the migration newly introduced are dropped. The in-progress record is cleared before the schema is rolled back, the tables leave maintenance mode, and the checkpoint is not advanced.tailor deploythen fails with the step's error under the codeMIGRATION_ROLLED_BACK, and its suggestion says the next deploy runs every step again. A migration that an earlier deploy left in progress is rolled back the same way once its script exports"rollback", including the tables and GraphQL permissions its earlier Pre-phase wrote. - Steps that already completed stay committed. Their changes to existing fields remain in the database, and nothing is recorded as in progress, so the next deploy runs every step again from the beginning. Every step must be safe to run again, or a rolled-back run applies those changes twice. Do not rely on data a step wrote to a field or table that the rollback removed.
- If
tailor deploycannot tell whether a step ran, or the migration's workflow execution may still be running (for example the connection to it was lost), it does not roll anything back even with"rollback": the migration stays in progress as described for amainscript below. A migration an earlier deploy left in progress is rolled back only when its earlier run is confirmed to have ended, so a failure before that run is inspected, such as a schema error while applying the Pre-phase, keeps it in progress. - If the in-progress record cannot be cleared,
tailor deploydoes not roll the schema back. It logs a warning and keeps the migration in progress with its tables in maintenance mode, because a record that outlives a rollback would make the next deploy resume against a schema its completed steps no longer match. Deploy again to resume it as described above, or roll it back without deploying withtailor tailordb migration resolve --rollback. - If the schema or GraphQL permission of some tables cannot be rolled back,
tailor deploystill fails with the step's original error. It also warnsMigration <namespace>/<N> was not rolled back completelyand lists the tables that need manual repair, and separately the tables whose GraphQL permission could not be set back. A table that still has the migration's pre-migration schema does not stop the next deploy, which runs every step again (see Failure Recovery). Otherwise the next deploy stops with remote schema drift, so check the tables in the workspace and repair them by hand.
Settling a migration that stays in progress
While a migration is in progress, migration sync and migration rebaseline refuse to run, and migration test refuses to use that workspace as its source. tailor tailordb migration status reports the migration as in progress and lists the commands that get it out of that state: tailor deploy to resume a multi-step migration, and tailor tailordb migration resolve to roll it back or complete it. For a migration whose script run a deploy could not confirm, it lists only resolve, because a deploy does not resume that migration.
A deploy resumes a multi-step migration left in progress. When you do not want it to — you abandoned the migration, or you repaired the data by hand — settle the migration with tailor tailordb migration resolve. Pass exactly one of two options:
--rollbacksends the schema of the migration before the failed one, clears the in-progress record, and leaves the checkpoint where it was. The nexttailor deployruns the failed migration again from its first step, so every step must be safe to run again. Data changes made by steps that already completed are not undone, and tables that exist only in the failed migration's schema are deleted with their data.--completesends the schema of the failed migration itself, clears the in-progress record, and moves the checkpoint to that migration, without running itsmigrate.tsagain. Repair the data first, for example withtailor query, and make sure no record lacks a value for a field the migration makes required: the update is rejected while such a record exists, and the migration stays in progress.
Tables that the failed migration removes are deleted with their data under --complete. The command refuses to run while an execution of the failed migration is still running; wait for it to finish first. Both options take the namespace's tables out of maintenance mode and remove the temporary resources of the failed run. The command shows what it will change and asks for confirmation (--yes skips it). As with migration sync, the local migration history must reproduce the current table definitions, so settle the migration before you change the tables. If sending the schema stops partway, run the same command again.
--rollback also accepts a migration that no deploy recorded as in progress, but whose pre-migration schema or maintenance mode a deploy left in place: the deploy stopped before it recorded the migration, or failed to roll it back or to end maintenance mode (see Maintenance mode). The command runs only when every table of the namespace has the schema of the checkpoint or the migration's pre-migration schema. With maintenance mode on, a table that has the pre-migration schema must also be in maintenance mode. If only maintenance mode is left, the command runs only when a local migration follows the checkpoint. A deploy that is still running looks the same until it records the migration, so make sure none is running. --complete refuses such a migration.
The same command settles a migration whose script run a deploy could not confirm (see Failure Recovery). A deploy does not resume such a migration; it stops until this command settles it. The command reads how that run ended and shows it before asking for confirmation. Pass --complete when the run succeeded, and --rollback when it failed, was canceled, or did not start; --rollback deletes the tables and fields the migration introduced together with the data the script wrote to them, and the next deploy runs the script again. The command warns when the option you passed does not fit how the run ended, or when it cannot read the run.
Supported Schema Changes
| Change Type | Breaking? | Migration Script? | Notes |
|---|---|---|---|
| Add optional field | No | No | Schema change only |
| Add required field | Yes | Yes | Script populates default values |
| Remove field | No | Optional | Warning tier — no script is auto-generated, but you can add one with tailordb migration script to preserve or clear data before the field leaves the active schema. The field stays readable from migrate.ts during Pre-migration. |
| Rename field | Yes | Yes | Confirmed interactively at generate time or via --rename "Table.oldField:newField" — see Renaming a field. Auto-generated script copies values from the old field to the new one; both fields coexist during Pre-migration. |
| Remove nested member | No | Optional | Warning tier — see Renaming a member inside a nested field. The member stays readable from migrate.ts during Pre-migration. |
| Add required nested member / make member required / remove nested enum values | Yes | Yes | Pre-migration relaxes the member constraints. Complete the generated TODOs to transform the nested values before Post-migration. |
| Change nested member type / array-ness / decimal scale / foreign key target | Rejected | — | Add a new member, transform and copy values, then remove the old member in separate migrations. |
| Rename nested member | Yes | Yes | Confirmed interactively at generate time or via --rename "Table.field.oldMember:newMember". Auto-generated script rewrites each row's nested value; the old member stays readable and the new member is optional during Pre-migration. |
| Change optional → required | Yes | Yes | Script sets defaults for null values |
| Change required → optional | No | No | Schema change only |
| Add index (non-unique) | No | No | Schema change only |
| Add unique index | Yes | Yes | Script must resolve duplicate value combinations across the index fields |
| Change unique index fields | Yes | Yes | Treated like adding a new unique constraint over the new field set |
| Remove index | No | No | Schema change only (removing the unique constraint from an index is also non-breaking) |
| Add unique constraint | Yes | Yes | Script must resolve duplicate values |
| Remove unique constraint | No | No | Schema change only |
| Change decimal scale | Yes | Yes | Auto-generated script re-saves existing rows under the new scale. Decreasing scale rounds values half-up and can lose precision. If the same change adds a unique constraint, duplicate handling runs after re-saving. |
| Add enum value | No | No | Schema change only |
| Remove enum value | Yes | Yes | Script migrates records with removed values |
| Add table | No | No | Schema change only |
| Remove table | No | Optional | Warning tier — no script is auto-generated, but you can add one with tailordb migration script to preserve data before the table leaves the active schema. The table stays readable from migrate.ts during Pre-migration. |
| Rename table | Yes | Yes | Confirmed interactively at generate time or via --rename "OldTable:NewTable" — see Renaming a table. Auto-generated script copies all rows preserving ids; both tables coexist until post-migration cleanup. |
| Change foreign key target table | Yes | Yes | Script updates references to the new target |
| Add relation to existing field | Yes | Yes | Script must resolve values that point at no record. The foreign key is withheld until Post-migration. A relation added to a nested member is non-breaking — see Changing nested member constraints. |
| Change field type (verified pair) | Yes | Yes | In-place for the pairs listed under Field type changes; review the generated normalization scaffold and customize it only when existing values need transformation |
| Change field type (other pair) | Yes | Yes | Two migrations, generated together after you confirm — see Converting a field type. Edit the conversion in the first; the second needs no changes. |
| Change array → single value | - | - | Not supported — see Converting a field type |
| Change single value → array | Yes | Yes | Two migrations, generated together after you confirm — see Converting a field type. Each stored value becomes a one-element array, so the conversion needs no edits. |
Field type changes
These pairs change in place, in a single migration:
| From | To |
|---|---|
integer | string, float, decimal |
float | string, decimal |
decimal | string, float |
boolean | string |
uuid | string |
enum | string |
Every pair here accepts every value its source type allows, which is what lets the change happen in one migration: the field keeps its previous type until the migration finishes, so your application can keep writing to it throughout.
Every other scalar pair needs a temporary field. Eligible fields can use the generated expand-contract pair; fields the generator rejects still need the manual three-step sequence described below. Three groups are worth calling out:
- Converting to a narrower type —
string→integer,string→uuid,integer→booleanand similar — is excluded because values the source type still accepts, such as"abc"in astringfield, cannot be cast. Your script could clean up the rows it sees, but the field goes on accepting new uncastable values until the migration completes. boolean→integer,float→integer, andstring→dateare excluded because the stored values cannot be cast to the new type.date,datetime, andtimefields are not stored in the textual form you wrote them in, so converting them to or from another type reads back as a different instant. Convert them through a temporary field where your script controls the formatting.
Converting to decimal reformats values to the field's scale, so 42 reads back as 42.000000. Values with more decimal places than the scale allows are rounded half-up, so 1.1234567 becomes 1.123457 at the default scale of 6.
A float field that is already unique cannot convert to decimal in place, because rounding can turn two distinct values into the same one and the existing constraint leaves no room to resolve the collision. Use the 3-step migration for those.
Generated normalization script for field type changes
For example, changing User.age from integer to float generates a migrate.ts that scans non-null values in batches of 100:
typescript
import { TODO, type MigrationSteps, type Transaction } from "./db";
async function updateUserAge(trx: Transaction): Promise<void> {
// Normalize User.age from integer to float while the previous type is still active
{
let lastId: string | undefined;
while (true) {
let query = trx
.selectFrom("User")
.select(["id", "age"])
.where("age", "is not", null)
.orderBy("id", "asc")
.limit(100);
if (lastId) {
query = query.where("id", ">", lastId);
}
const rows = await query.execute();
if (rows.length === 0) break;
for (const row of rows) {
const sourceValue = row.age;
if (sourceValue === null) continue;
const normalizedValue = TODO(
"normalize User.age to a value the active integer type accepts and the float type can cast",
);
if (Object.is(normalizedValue, sourceValue)) continue;
await trx
.updateTable("User")
.set({ ["age"]: normalizedValue })
.where("id", "=", row.id)
.execute();
}
lastId = rows[rows.length - 1]!.id;
}
}
}
export const steps = {
updateUserAge: { run: updateUserAge },
} satisfies MigrationSteps;The generated TODO() call stops the migration until you replace it, and tailordb migration validate and tailor deploy reject the migration while it is still there. If the existing values are already suitable for the target type, replace the call with sourceValue to accept the identity transformation; it does not write any rows. If values need application-specific normalization, replace it with your expression, keeping the result valid for both the active source type and the target type. The source field contract remains active until the script finishes; for example, an integer → float script cannot write fractional values during this phase.
Converting a field type
A field type change outside the verified in-place pairs — string → integer, for example — cannot be applied in one step, because the field would have to hold both shapes at once. The same holds when a single value becomes an array (string → string[]): the stored values must be rewritten as arrays before the field can take the new shape. migration generate offers to carry the values through a temporary field instead:
User.price changes from string to integer, which cannot be applied in one step.
? Generate two migrations to convert User.price through a temporary field? (Y/n)Confirming writes two migrations:
- The conversion. Adds a temporary field (
priceMigrate), converts each stored value into it, and clears and removes the original field. Edit the conversion expression before deploying: the generatedTODO()call fails the migration, andtailordb migration validateandtailor deployreject it while the call is still there. When only the array-ness changes (string→string[]), the conversion stores each value as a one-element array and has noTODO()call; when the element type changes as well (integer→string[]), you convert the element and the script wraps it. - The rename. Renames the temporary field back to
price. Its copy script is complete, but this migration also carries every other schema change the same run picked up, so review it as you would any generated migration.
If converting to an array also reduces a decimal field's scale or removes enum values, the conversion keeps the TODO() call. Edit the element conversion to satisfy the target field before deploying.
tailor deploy applies both. Because the conversion only touches rows whose original value is still set, a re-run resumes where it stopped rather than converting a row twice.
The original field is removed in the first migration rather than the second, because the rename needs its name free. Your script can still read it while the conversion runs.
In a non-interactive run — --yes, or CI — name each field explicitly:
bash
tailor tailordb migration generate --yes --expand-contract "User.price"Without the flag the command fails rather than converting anything, so a scripted run cannot start a two-migration change by accident.
Why the conversion clears the original field. Removing a field from the schema does not necessarily remove its stored value. Reusing the name for an incompatible type while a stale value remains can deploy successfully and then make subsequent reads fail. Clearing the original in the same update is what prevents that, which is why the generated script writes both fields at once.
Writes that land while the conversion runs are not carried across. The original field keeps its old type until the conversion finishes, so an application can still write to it after the script has read the last row — and that value is dropped with the field rather than converted. Stop writes to the field, or accept the loss, before deploying a conversion on a live workspace.
Some changes are still rejected and need a temporary field you add yourself — add the new field, write a script that fills it and clears the old one, then remove the old field and rename the temporary one in a later migration:
- A field that is already an array: collapsing it into a single value has no answer the generated script could choose for you, and changing its element type (
string[]→integer[]) is not generated either. - A field that is unique, or that an index, relationship, permission, or table-level script names. Those keep pointing at the original name, which the conversion removes.
Testing Pending Migrations
tailor tailordb migration test runs every migration pending in the source workspace against an isolated workspace. The source workspace is selected by --workspace-id or the active profile and is never modified.
The command performs the following sequence:
- Reads each migration-enabled namespace's
sdk-migrationcheckpoint from the source workspace and reconstructs that exact snapshot from local migration history. A deployed namespace that has no checkpoint is treated as migration 0 when its schema matches the0000snapshot, astailor deploydoes; otherwise the command fails and lists the differences. A namespace that was never deployed, or has no tables yet, also fails. - Creates a temporary workspace in the same region as the source, unless
--target-workspace-idnames an existing throwaway workspace. By default it is created in the source's organization and folder.--organization-idplaces it at the root of that organization, and--folder-idplaces it in that folder (together with--organization-idwhen the folder is in another organization or the source is not in an organization). With--target-workspace-id, the given values must match where that workspace already is. - Deploys the checkpoint snapshots and writes their checkpoint labels.
- Loads fixture data or clones source records.
- Runs the normal deployment pipeline, including every pending pre-migration,
migrate.ts, and post-migration phase. - Optionally runs an assertion script against the migrated data.
- Deletes an automatically-created workspace after success or failure.
Right after creating an automatically-created workspace (step 2), the command records a 24-hour expiry on it. If the run is interrupted before step 7 — for example, a CI job is cancelled or reaches its time limit — the command cannot delete the workspace, so it stays. Once the 24 hours have passed, tailor workspace prune --expired (with a location option such as --organization-root, --folder-id, or --personal) deletes it. A workspace given with --target-workspace-id is not given an expiry. If recording the expiry fails, the command warns and continues the test.
Both the pre-migration and final TailorDB schemas come from committed migration snapshots. Ungenerated changes in the current table definitions are not included in the rehearsal.
Executors are omitted from the baseline deployment so loading fixture or cloned records cannot trigger current event handlers against the older schema. Auth user profiles are also deferred until the pending migrations finish, while configured machine users remain available to run seed and migration scripts. The final deployment restores the configured executors and user profiles. Static websites are deployed so configuration references to their URLs resolve, but their workspace-bound custom domains are omitted from migration-test deployments.
Seed mode
Seed mode is the default and uses the JSONL files produced by the configured seedPlugin. Run tailor generate after adding the plugin or changing seed tables, then populate its data/*.jsonl files:
bash
tailor tailordb migration test --data seedRows are loaded only for tables present in the deployed pre-migration snapshots (and current schemas without migrations), in foreign-key dependency order. Fields introduced by pending migrations, including timestamp and nested fields, are removed before insertion so current fixtures can be loaded into the baseline schema. Missing table files are treated as empty. IdP _User fixtures are not loaded by this command.
Use --machine-user to run the seed, the pending migration scripts, and the assertion as that machine user, in place of db.<namespace>.migration.machineUser and the first entry in auth.machineUsers (and, for the seed, the seed plugin's machineUserName). For the pending migration scripts, it must be defined in auth.machineUsers.
Clone mode
Clone mode copies TailorDB records from the source workspace after the identical application, namespace names, and pre-migration schemas exist in the target. For namespaces without migration history, the command reproduces the deployed source schema rather than uncommitted local table changes:
bash
tailor tailordb migration test --data cloneCloning requires editor access to both workspaces, which must be in the same region. Clone mode copies TailorDB records only: IdP users, file blobs, and metadata labels are not copied, and unlike tailor data clone it has no --copy-files or --copy-idp-users option. File fields therefore retain references whose blobs are absent. The command polls the asynchronous operation and reports platform failures.
With --target-workspace-id, the designated target must be in the same region as the source; the command checks this before deploying anything to it.
The source schema is re-verified immediately before cloning; if the source workspace was deployed or otherwise changed after the test started, the command aborts instead of cloning data that no longer matches the deployed baseline.
Assertions and retained targets
Pass a TypeScript file with --assert. Its exported main function uses the same Kysely transaction signature as migrate.ts, runs after all pending migrations, and must throw when an invariant fails:
bash
tailor tailordb migration test \
--data seed \
--assert ./tests/assert-customer-email.ts \
--assert-namespace tailordb--assert-namespace is inferred when only one namespace has pending migrations and is required otherwise.
To inspect the result after a run, pass --keep so the automatically created workspace survives instead of being deleted, on success and on failure:
bash
tailor tailordb migration test --keepA kept workspace records no expiry unless you also pass --keep-ttl <age> (for example --keep-ttl 7d), so tailor workspace prune --expired does not delete it.
Alternatively, provide an empty designated throwaway workspace. This mode never deletes the target and requires explicit acknowledgment:
bash
tailor tailordb migration test \
--target-workspace-id 00000000-0000-4000-8000-000000000000 \
--yesDo not target a shared development or production workspace: baseline deployment reconciles its managed resources and schemas before the migration test runs.
Automatic Migration Execution
When you run tailor deploy, the SDK detects pending migrations (anything past the current sdk-migration label on the deployed namespace) and runs them in order before continuing with the rest of the apply.
Per-migration phases
For each pending migration:
- Pre-migration: Schema changes that would be breaking are applied in a relaxed form first. A verified in-place field type change keeps its complete previous field contract until Post-migration, including field and table-level hooks or validators changed by the same migration. Newly-required fields are added as optional; fields whose
optional → requiredtransition is breaking are temporarily kept optional. Fields that are being removed in this migration are temporarily kept on the table so thatmigrate.tscan still read them (for example, toinnerJointhrough a foreign key that is about to be dropped); members removed from a nested field are kept the same way, and the new member of a confirmed nested rename is added as optional and non-unique. Added or newly required nested members are optional and removed nested enum values remain allowed until Post-migration. For a renamed field, the old field is kept and the new field is added with its constraints relaxed, so the script can read the old field and write the new one. For a renamed table, the new table is created with its full constraints while the old table stays on the namespace until post-migration cleanup, so the script can copy rows between them. A foreign key added to an existing top-level field is withheld until Post-migration, somigrate.tscan update rows whose values point at no record; Post-migration adds it without checking the stored values. Breaking table-level index changes are relaxed the same way: a newly-added unique index is withheld, and an index gaining a unique constraint (or a unique index changing its field set) keeps its previous definition, somigrate.tscan resolve duplicates first. Non-breaking changes that are part of the same migration are also applied here. - Script execution: If
migrate.tsexists on disk for this migration, it is bundled and runs on the platform as the configured machine user inside a transaction — or, for a script that exportssteps, one transaction per step. The script is hard-required for breaking changes (diff.requiresMigrationScript) — deploy fails if the file is missing, unless a--no-scriptacknowledgment was recorded (see Breaking changes without a script). It is also executed when present for warning-tier diffs — see Warnings and optional migration scripts. - Post-migration schema: Required constraints and the target field definitions are applied. Do not assume that removing a field clears its underlying stored JSON value.
- Checkpoint and cleanup: The
sdk-migrationlabel is bumped to this migration's number, then removed GQL permissions and tables — including the old table left behind by a rename — are deleted. Advancing the checkpoint first prevents a failed checkpoint write from requiring the SDK to recreate irreversibly deleted records.
This split is what allows existing rows to be backfilled before the database starts rejecting nulls, and what lets migrate.ts traverse foreign-key fields that the same migration removes.
Maintenance mode
maintenanceMode in defineConfig decides whether tailor deploy puts each namespace with pending migrations into maintenance mode while it applies them. A table in maintenance mode accepts no GraphQL create, update, delete, or bulk upsert operation and publishes no record events; its read setting is left as it is.
| Value | Maintenance mode |
|---|---|
false | None. Nothing is restricted while migrations run. |
"migration" (default) | From before the first pending migration runs until the last one completes. |
"deploy" | From before the first pending migration runs until the deploy has applied every other change, including resolvers, executors, and workflows, and has removed the resources it deletes. |
A deploy without pending migrations never enters maintenance mode. When maintenanceMode is unset, it behaves as "migration", preserving the existing deployment behavior. The value is read from the config like any other, so it can differ per environment (see Multi-Environment Configuration):
typescript
export default defineConfig({
name: "my-app",
maintenanceMode: process.env.APP_ENV === "production" ? "deploy" : false,
});Without maintenance mode, the SDK does not guarantee what requests that run during the migration observe. A table can still have its pre-migration shape, with new required fields optional and removed fields present; a client can write rows after migrate.ts has already read the table; and the records migrate.ts writes publish events to the executors of the previous deploy, which were written for the old shape. Use it only where clients and executors tolerate that.
"migration" vs "deploy": the new resolvers and executors are deployed after the migrations. With "migration", the migrated tables accept requests and publish events again while the resolvers and executors of the previous deploy are still in place. "deploy" keeps the tables in maintenance mode until they are replaced.
Maintenance mode restricts GraphQL writes and record events only. Resolvers, executors (including scheduled and webhook triggers), and workflows keep running, and the writes they make through Kysely are not blocked.
When a deploy stops early:
- If a migration fails, the tables leave maintenance mode before the error is reported: they return to the settings of the last checkpoint that completed. A migration that did not reach its checkpoint returns existing tables to their previous settings and drops the tables it created (see Failure Recovery). A created table that the rollback cannot drop stays in maintenance mode, and the deploy names it; deploying again runs the migration again. The settings are not restored if the checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified. A table left behind by a failed post-checkpoint deletion stays in maintenance mode until the next deploy deletes it (see Failure Recovery).
- With
--no-schema-check, if a local table definition that the migrations do not include cannot be applied after they complete, the existing tables leave maintenance mode with the definitions of the last migration before the error is reported (see Schema verification). - A multi-step migration left in progress keeps its tables in maintenance mode until a deploy completes it.
- A migration whose script run a deploy could not confirm keeps its tables in maintenance mode until you settle it with
tailor tailordb migration resolve(see Failure Recovery). - With
"deploy", if a later part of the deploy fails, the tables leave maintenance mode before the error is reported. When the deploy ends maintenance mode, it leaves the tables of a namespace unchanged if that namespace's checkpoint number or migration history changed concurrently, or if checkpoint ownership cannot be verified, and reports it. - If the deploy process is interrupted after a namespace's migrations completed but before maintenance mode ended, that namespace's tables stay in maintenance mode until a later
tailor deployreleases them when it applies its TailorDB changes, before it replaces resolvers and executors. A namespace whose migrations had not all completed is not released this way: the next deploy resumes a multi-step migration left in progress, and stops at a migration whose script was running until you settle it. Every migration checkpoint of a namespace records the maintenance mode, so with pending migrations0001and0002, a deploy interrupted right after the checkpoint of0001leaves the tables in maintenance mode; the next deploy keeps them there while it applies0002and releases them afterwards (with"deploy", after the whole deploy). - A deploy interrupted before it recorded a migration as in progress (for example, right after the migration's pre-migration schema changes reached some or all of its tables) leaves no record of that migration. The next deploy accepts tables that have the schema of the checkpoint or the migration's pre-migration schema, and runs the migration again from the start. To return the tables to the checkpoint without deploying, run
tailor tailordb migration resolve --rollback(see Settling a migration that stays in progress).
Keeping maintenance mode short
Maintenance mode lasts for the whole migration job, including the time the platform takes to start it, which can far exceed the time migrate.ts itself needs. Keep in migrate.ts only the work that must be done before the post-migration schema is applied, and move the rest into a workflow that you start after the deploy (for example with tailor workflow start).
- Must stay in
migrate.ts: anything the post-migration schema enforces or removes — filling a field that becomes required, resolving duplicates before a unique index, converting values for a field type change, and copying data out of a field or table that the migration renames or removes. - Can move to a workflow: work the post-migration schema does not depend on, such as filling an optional field, recomputing derived values, or cleaning up rows. Requests that run before the workflow finishes see the data in its unfixed state.
Time spent in maintenance mode
When maintenance mode is on, tailor deploy reports how long the migrating namespaces' tables were restricted, split into phases. With "deploy", the window also includes the remaining deployment changes and ends when their restrictions are lifted. No maintenance report is emitted with maintenanceMode: false.
| Phase | Covers |
|---|---|
restrict | Putting the tables into maintenance mode |
pre-migration | Pre-migration schema changes |
job setup | Bundling migrate.ts and registering it as a job |
waiting to start | Time when the platform has accepted the job but none of its code runs yet |
running | Time when the script runs |
job cleanup | Collecting the script's logs and removing its job, unless its steps run as separate jobs |
post-migration | Post-migration schema changes, the checkpoint update, cleanup, and local definitions no migration includes |
remaining deploy | With "deploy", applying the remaining deployment changes while tables stay restricted |
restore | Taking the tables out of maintenance mode |
Phases that repeat across several migrations are added up. The report is printed only after the migrations succeed and the tables leave maintenance mode; when a migration fails, deploy reports the error instead (see Failure Recovery). When deploy resumes a migration that an earlier deploy left in progress, the tables were already in maintenance mode before it started, and the report covers only this deploy. A job can be reported as started on the platform well before its code runs, so deploy tells waiting from running by a line every job of a migration logs before your script: [tailor-sdk] migration script started. You may see that line in function execution logs; deploy leaves it out of the logs it prints. A script that exports steps waits for each step's job to start, and that time counts as waiting to start too. Time when deploy cannot tell whether the script is running — because the run or a running job's logs could not be read, or because an older SDK version started the run and it never logs the line — is reported as waiting to start or running instead.
Deploy checks the run every few seconds, so a boundary between waiting to start and running can be off by that much. With --json, the same breakdown is in tailordbMaintenance (see Deploy JSON result). Add --verbose to also print how long each phase took and every change in the job's status.
Schema verification
Before running migrations, apply performs two checks:
- Local schema check — your current table definitions must match the latest snapshot in
migrations/. If they don't, you forgot to runmigration generate. - Remote schema check — the deployed schema is reconstructed from migration history; the actual remote schema must match. Drift here means someone applied a different set of migrations or edited the schema out-of-band. A namespace that has tables but no migration label yet must match
0000(see Adding migrations to an existing project). When no migration is recorded as in progress, a table may also have the pre-migration schema of the next migration, which an interrupted or failed deploy left; the deploy then runs that migration again from the start (see Maintenance mode). With maintenance mode on, such a table must be in maintenance mode.
On drift you'll see something like:
✖ Remote schema drift detected:
Namespace: tailordb
Remote migration: 0007
Differences:
Table 'User':
- Field 'email': required: remote=false, expected=trueThe error also points you at migration status, migration generate, and migration sync — see Remote schema drift detected for which one applies.
To run the same checks without deploying — plus migration file integrity (numbering, parseable contents, a migrate.ts or a recorded --no-script acknowledgment for every migration that requires a script, and no TODO() call left in a migrate.ts):
bash
tailor tailordb migration validateIt reports issues per namespace, exits with a non-zero code when any check fails, and supports --json for machine-readable output.
With --strict, validation additionally fails when a migration not yet applied to the remote has data-loss warnings (see Warnings and optional migration scripts) but neither a migrate.ts nor a recorded --no-script acknowledgment. The failure names the affected table and field and prints the exact command to record the acknowledgment.
To bypass both checks during deploy (not recommended outside of recovery scenarios):
bash
tailor deploy --no-schema-checkWith pending migrations, each migration still applies its own table definitions in its phases, so migrate.ts sees the schema it was written for. Local table definitions that differ from the last pending migration are applied after every pending migration has completed, and the deploy lists those tables. With maintenance mode on, existing tables stay in it until their local definitions are applied. If one of them cannot be applied, for example a unique constraint on a field whose records hold duplicate values, the deploy fails with MIGRATION_LOCAL_DEFINITIONS_FAILED: the migrations stay applied, tables already created for their local definitions remain, and the existing tables keep the definitions of the last migration and leave maintenance mode.
--no-schema-check does not bypass the check that the remote migration checkpoint is in the local migration history. See "Remote migration checkpoint is not in the local migration history" error.
Example output
ℹ Executing 1 data migration(s)...
ℹ Using machine user: admin-machine-user for namespace 'tailordb'
10:42:31 AM ℹ Migration tailordb/0002 started running after waiting 15m02s for its job to start.
✓ Migration tailordb/0002 completed successfully (waiting to start 15m02s, running 1m41s)
✔ All data migrations completed successfully.
ℹ Tables of namespace tailordb were in maintenance mode for 16m56s (restrict 1.2s, pre-migration 3.4s, job setup 2.1s, waiting to start 15m02s, running 1m41s, job cleanup 0.8s, post-migration 4.0s, restore 1.1s).
✔ Successfully applied changes.Re-baselining a deployed migration history
tailor tailordb migration rebaseline collapses the complete history into a new 0000/schema.json in the current migration format. It does not modify the deployed schema or data.
Before running it:
- Apply the latest migration to every environment. The CLI verifies the connected workspace, but it cannot inspect other workspaces.
- Commit or otherwise preserve the existing migration history. Files after
0000, includingmigrate.tsanddb.ts, disappear from the working tree; Git history retains committed files. - Make sure local table changes have been captured with
tailor tailordb migration generate.
Then re-baseline one namespace:
bash
tailor tailordb migration rebaseline --namespace tailordbThe command validates the migration files, verifies that replaying the latest migration exactly reproduces the local table definitions, and checks that the connected workspace is at that latest migration with no schema drift. After confirmation, it replaces the local history with the reconstructed baseline, records a new migration history ID in both 0000/schema.json and remote metadata, and resets the connected workspace's sdk-migration label to 0000. Use --yes only after arranging the same operational preconditions in non-interactive automation.
When the connected workspace records another migration history ID, or one that cannot be read, the command refuses to run. Run it from a checkout of the migration history deployed to that workspace. To replace the workspace's history with the local one on purpose, first run tailor tailordb migration sync <N> --force with the latest local migration as <N>, which rewrites the workspace's tables to that migration, removing the fields it does not have and deleting the tables it does not have together with their data (see migration sync Semantics). When the workspace's tables differ from the latest migration, the command also refuses to run; see Remote schema drift detected, where tailor tailordb migration sync <N> rewrites the tables to the latest migration.
Commit the resulting migrations/ change before generating any new migrations. For another environment still carrying the exact checkpoint and history ID that the new baseline replaced, the next tailor deploy checks whether its remote schema exactly matches the new 0000. If it does, deploy offers to reset the checkpoint to 0000 and move the environment to the new history ID before applying any later local migrations. A markerless history is eligible only for the first rebaseline, at the exact migration recorded as replaced. Any other checkpoint or history ID is rejected without changing remote metadata, even if its schema happens to match the baseline.
Partial squashing is not supported: re-baselining always replaces the full history for one namespace.
migration sync Semantics
tailor tailordb migration sync <N> reconstructs the schema snapshot at migration N from the working tree's migration history and overwrites the remote schema to match it, then sets the sdk-migration label to N and records the migration history ID of the local baseline. It never runs migrate.ts scripts itself — it only changes what the next apply considers pending:
| Movement | Effect on next apply | Effect on data |
|---|---|---|
Backward (e.g., 0003 → 0001) | Migrations 0002 and 0003 become pending and re-execute, including their migrate.ts. | Tables absent from snapshot 0001 are deleted along with their data; re-executed scripts may rewrite data. |
Forward (e.g., 0001 → 0003) | Migrations 0002 and 0003 are skipped — their migrate.ts scripts will not run. | Data the skipped scripts would have migrated stays as-is. |
Before anything is sent to the remote, sync verifies that replaying the full migration history reproduces the current local table definitions. If it does not — because migration files were edited and no longer match, or because a schema change has not been recorded with migration generate yet — the command fails without touching the remote. This means a rewritten migration history is validated before it can overwrite the deployed schema.
sync refuses to run when the namespace records another migration history ID, or one that cannot be read, before it reads or changes any table, unless the namespace has neither a checkpoint nor a history ID yet. Run it from a checkout of the migration history deployed to the workspace. To write the local history over another one on purpose, for example after migration rebaseline re-baselined the local history but could not update the workspace, pass --force: it overwrites the tables with those of the local history and replaces the history ID with the local one, or removes it when the local history has none, and still asks for confirmation unless you pass --yes.
Because syncing backward causes already-applied scripts to re-execute on the next deploy, write migrate.ts scripts to be idempotent (see Performance and Large Tables for resumable where clauses).
The main use cases are:
- Recovering from drift after a
deploy --no-schema-check, which deploys the local table definitions without checking them against the migration snapshots: runmigration sync <N>to restore the remote to a known snapshot, thentailor deployto apply the remaining migrations from the working tree. - Moving a checkpoint that
tailor deployreports as not in the local migration history back to that history on purpose (see "Remote migration checkpoint is not in the local migration history" error). - Recording that the tables already match migration
N, for example after you fixed them by hand or applied a migration outside the migration history.sync <N>sends the same table definitions again, so only the checkpoint changes, although its confirmation still lists the tables as updated. - Re-running a faulty migration in a development workspace (see Rollback Strategy).
Team Workflow and CI/CD
Branch coordination
Migration numbers are assigned sequentially, so two developers branching off the same point and each generating 0005 will collide. Conventions that work:
- Don't generate migrations on long-lived feature branches. Generate them just before merge, after rebasing onto main.
- Resolve collisions by re-generating. If your branch has
0005but main now has0005from another PR, regenerate yours as0006— see Resolving a migration number conflict. - Treat migration files as merge-conflict-prone. They are committed JSON and TypeScript, so review them in PRs. The
diff.jsonis the source of truth — if review focuses there, regenerating after rebase is straightforward.
Resolving a migration number conflict
When your branch and main each generated the same number, merging or rebasing stops with an add/add conflict on migrations/0005/diff.json. Resolve it by re-generating your migration on top of main's:
- Save your script edits aside. If you customized
0005/migrate.ts, keep a copy before touching the directory — during a rebase,git show ORIG_HEAD:migrations/0005/migrate.tsprints the version from your pre-rebase branch tip. - Take main's
0005/directory in full. Accept main's version of every conflicting file. Then check for files only your side added: if your migration has amigrate.tsand main's does not, that file never conflicts — it silently stays next to main'sdiff.json. Delete such leftovers explicitly. - Finish the rebase or merge, then re-run
migration generate. With main's migration now part of local history, the diff is computed against the correct base — including main's changes — and your migration lands as the next number (0006). - Port your script. Copy the logic saved in step 1 into the newly scaffolded
0006/migrate.ts. For a warning-tier change,migration generatedoes not scaffold a script — recreate it first withtailor tailordb migration script 0006.
When a plain rename is enough. If the two migrations touch disjoint tables and fields, renaming your directory to the next free number (keeping main's 0005/) can be acceptable. Run tailor tailordb migration validate after the rename: if it reports a mismatch, the migrations were not disjoint — discard the rename and re-generate as above. A passing check covers only the schema history, not your script: migrate.ts now runs after main's migration, so confirm it does not read or write tables that migration touches — when in doubt, re-generate.
CI / CD
- For non-interactive environments, pass
--yestomigration generateand--yestoapply.applyruns migrations automatically when themigrations/directory is configured. - Run
tailor tailordb migration validatein CI to catch, before deploying, uncommitted migrations, broken migration files, unreviewed generated normalization logic, remote schema drift, and pending migration scripts thattailor deploycould not run. It exits with a non-zero code when validation fails and supports--json. Add--strictto also require an explicit acknowledgment (amigrate.tsor a recorded--no-scriptreason) for every pending migration that can drop data, so destructive changes cannot merge unnoticed. tailor tailordb migration statusvalidates the layout and file-format compatibility of the full local history, compares its history ID with the deployed namespace, and shows applied and pending migrations for a human-readable comparison. Its exit code is non-zero on invalid or incompatible files, migration history mismatches, a remote checkpoint ahead of the latest local migration, and remote read errors, so check the output.- Avoid running migrations in parallel against the same workspace — there is no locking. Serialize deploys per environment.
Resetting a deployed project
Use tailor tailordb migration rebaseline rather than combining migration generate --init with a manual checkpoint change. See Re-baselining a deployed migration history for the required cross-environment coordination and verification.
Failure Recovery
A script that exports steps follows different rules once one of its steps has completed: the migration stays in progress and the next deploy resumes it, unless the script exports onFailure = "rollback". See Splitting a migration into steps and Rolling back instead of resuming. Otherwise, if the pre-migration phase or migrate.ts fails:
- The transaction rolls back for that migration's script. Database changes the script made are undone.
- The pre-migration schema changes are rolled back to the prior checkpoint: tables that already existed are restored to their previous shape and GraphQL permissions, and tables the migration newly introduced are dropped. The workspace is left at its prior checkpoint and prior schema — not half-applied.
- The whole
applyaborts and the checkpoint label is not bumped. Subsequent migrations in the same run do not execute.
The rollback is best-effort per table; if reverting a table fails, a warning is logged and the original migration error is still reported. A table left with the migration's pre-migration schema does not stop the next deploy, which runs the failed migration again from the start. This happens, for example, when the script wrote records without a value for a field the migration makes required: the platform refuses to make the field required again while such records exist. tailor tailordb migration resolve --rollback returns such tables to the checkpoint without deploying, once those records have a value. A table left in any other shape makes the next deploy stop with remote schema drift on that table. Run tailor tailordb migration validate to see which tables differ, return the namespace to its checkpoint with tailor tailordb migration sync <N>, where <N> is the checkpoint tailor tailordb migration status reports, and deploy again. sync deletes the tables and fields that are not in that migration's snapshot, together with any rows the failed migration wrote to them; the next deploy runs the failed migration again.
If tailor deploy cannot tell whether a main script ran, for example because the connection dropped while it started or waited for the script, it does not roll anything back: the pre-migration schema changes stay, the namespace's tables stay in maintenance mode if it is on, the migration stays recorded as in progress, and the deploy reports that it could not confirm the outcome. The same holds when the deploy process stops while the script runs. Until you settle the migration, tailor deploy stops before changing anything, including the other namespaces and resources of the application, and reports how the run stands, even with --no-schema-check. Run tailor workflow executions --workflow-name <name> with the workflow name the error reports until the execution has finished. If it succeeded, run tailor tailordb migration resolve --complete, which applies the migration's schema without running the script again; otherwise run tailor tailordb migration resolve --rollback, and the next deploy runs the script again. See Settling a migration that stays in progress, which also lists the commands that refuse to run until then.
If the post-migration schema changes fail after a main script succeeded and the deploy cannot clear the in-progress record, it keeps the pre-migration schema instead of rolling it back, so the data the script wrote stays. Settle the migration as above: --complete once the data meets the migration's schema, or --rollback.
When a steps migration's post-migration schema changes fail and the deploy cannot return its tables to the pre-migration schema, it logs a warning. The next deploy accepts each table in either the pre-migration or the post-migration schema: it applies the pre-migration schema again, skips the completed steps unless the steps changed, and retries the post-migration schema changes.
After a failure:
- Read the
Logs:block in the apply output to find the cause. - Fix
migrate.ts(or the data it depends on). - Re-run
tailor deploy. The same migration runs again because its label was never bumped, and the prior-checkpoint schema is a clean baseline to retry against.
If a migration succeeds in script but its reversible post-migration schema update fails (rare; usually a constraint violation the script should have prevented), the SDK makes the same best-effort restoration to the prior-checkpoint schema. The script's committed data changes remain, so write migration scripts to tolerate re-execution.
Before a migration's script starts, tailor deploy checks that the namespace's checkpoint and migration history are still the ones it read before applying, or the checkpoint it committed itself. If another deploy or a tailor tailordb migration command changed them in the meantime, or the deploy cannot read them, the script does not start and the migration is not recorded as in progress: the deploy handles the pre-migration schema as for a script that failed before it ran, and fails with MIGRATION_CHECKPOINT_CONFLICT (MIGRATION_CHECKPOINT_UNVERIFIED when it cannot read them). Run tailor tailordb migration status to see the checkpoint. Wait for the other deploy to finish, or, when no other deploy is running, return the namespace to the checkpoint the error names with tailor tailordb migration sync <N>, which also lifts the maintenance mode the deploy left in place, and deploy again. If the error says the checkpoint changed in a different migration history, or with a migration history label that cannot be read, sync also needs --force, which replaces the workspace's migration history ID with the local one. Pass it only when the local migration history should replace the deployed one; otherwise deploy from a checkout of the migration history deployed to the workspace.
The checkpoint is advanced only after the reversible post-migration schema updates succeed. If the checkpoint write reports an error, the SDK reads it back: a matching value is treated as committed. Any other observed value leaves the post-migration schema unchanged rather than risk rolling back a concurrent deployment; a value beyond the current migration confirms a concurrent deploy, while an older or missing value means the checkpoint must be repaired before retrying, unless the value is the previous checkpoint and the multi-step migration is still recorded as in progress, which the next deploy resumes. If read-back also fails, the SDK likewise leaves the post-migration schema unchanged; verify the remote checkpoint before retrying.
Removed tables are deleted only after the checkpoint is committed. The deploy records with the checkpoint that these deletions are still to run, and clears the record once they finish. If that cleanup fails or the deploy is interrupted, the checkpoint remains at the new migration, and the next deploy finishes the cleanup: it lists the tables that migration removed among the tables to delete, and deletes them and their GQL permissions with their data before it runs any pending migration. tailor tailordb migration validate reports them as tables the next deploy deletes. To delete them without deploying, run tailor tailordb migration sync <N> with the committed migration as <N> (when the cleanup fails, the deploy error's suggestion names the command). A same-named remote table is still reported as remote schema drift when no unfinished cleanup is recorded, for example a table created again after the cleanup completed or one left by an SDK version that did not record the cleanup, and when a later migration adds the table back; delete it with migration sync <N> or by hand, and then deploy again. A deploy with --no-schema-check does not finish the cleanup of a namespace that has pending migrations, and once those migrations commit, the leftover tables are reported as drift as well.
With --no-schema-check, local table definitions that the pending migrations do not include are applied after every migration has committed (see Schema verification). If one of them cannot be applied, the deploy fails with MIGRATION_LOCAL_DEFINITIONS_FAILED: the migrations stay committed, the existing tables go back to the definitions of the last migration, and tables already created for their local definitions remain. Fix the cause and deploy again. When existing records refuse the definition, for example a unique constraint on a field whose records hold duplicate values, run tailor tailordb migration generate to add the change in a migration whose script updates those records, then deploy again; the error's suggestion names the command.
Rollback Strategy
There is no automatic down-migration. To roll back a schema/data change in production, write a new forward migration that reverses the previous one. For example, to undo a 0005 that added a required email field:
- Edit your table definitions to remove the field.
migration generate --name "rollback 0005 email"produces0006with a removal diff.- Apply.
In development workspaces, a quicker option is to fix 0005/migrate.ts in place, run migration sync <previous> to return the tables to the previous migration and re-mark 0005 pending, and apply. sync removes the fields 0005 added and deletes the tables it added together with their data, and stops before moving the checkpoint when existing rows have no value for a field the previous migration requires. Do not do this on production — it confuses migration history across environments.
Machine User and Permissions
Migration scripts execute server-side under a machine user identity. The CLI selects the user in this priority order:
db.<namespace>.migration.machineUserif set intailor.config.ts.- The first entry in
auth.machineUsersotherwise.
The selected user must be defined in auth.machineUsers. The CLI logs the selected user before running scripts (Using machine user: ...).
When a pending migration has a migrate.ts, tailor deploy checks while planning that auth is configured, that a machine user is selected and defined in auth.machineUsers, and that each script can be bundled. A failure stops the deploy before anything is applied, and tailor deploy --dry-run reports it too. tailor tailordb migration validate runs the same checks for the migrations that are pending in the workspace it validates.
Permissions required
The machine user needs read/write access to every table the migration script touches. If your migrations alter data across multiple tables, the simplest path is to give the migration user broad access (e.g., an ADMIN role) and restrict day-to-day machine users separately. If the user lacks permission, the script fails with a permission error in Logs:.
If you see No machine user available for migration execution, either:
- Add
machineUsers: { ... }to your auth config andtailor deployit, or - Set
migration.machineUserto a machine user defined inauth.machineUsers.
Multi-Namespace Coordination
If your project defines multiple TailorDB namespaces (db: { ns1: { ... }, ns2: { ... } }), each has its own migrations/ directory and its own migration label. During apply:
- Pending migrations run one namespace at a time, in alphabetical order of the namespace names, and within a namespace in migration number order. With pending migrations in
inventoryandbilling, every one ofbillingruns before any ofinventory. - With maintenance mode on, every namespace of the config that has pending migrations enters maintenance mode before the first migration runs, and they leave it together: after the last migration with
"migration", and after the whole deploy with"deploy". Maintenance mode describes the exceptions when a deploy stops early. - The order follows the namespace names, not dependencies between namespaces. Do not write a migration in
ns1that depends on data produced by a migration inns2running first. - Each namespace can specify its own
migration.machineUser.
Several configs in one deploy
When one deploy applies several configs (see Multiple Config Deploys), it applies each kind of resource for every config, in the order the configs are listed, before it moves on to the next kind. For tailor deploy --config supplier.ts,buyer.ts, the secrets, static websites, AI gateways, IdPs, auth services and machine users, and function registries of both configs are applied first. Then the TailorDB changes and migrations of supplier are applied, then those of buyer, then the resolvers of supplier and buyer, and so on through the applications, executors, and workflows. Each config's maintenanceMode covers only its own namespaces, so with "deploy" in supplier, its tables stay in maintenance mode while the migrations of buyer run.
If a migration of buyer fails, the deploy stops there and leaves supplier in this state: its migrations have completed and its checkpoint has advanced, its tables are out of maintenance mode even with "deploy", and nothing after TailorDB has been applied, including its resolvers, executors, and workflows. Its tables therefore have the new schema while the resolvers and executors of the previous deploy still use them. When you fix the migration of buyer and deploy both configs again, supplier has no pending migrations, and the deploy applies the rest of its changes. If the migration of buyer must first be settled with tailor tailordb migration resolve (see Failure Recovery), every deploy of the two configs stops before changing anything until then.
Performance and Large Tables
The migration script runs in a single transaction. For tables with many rows:
- Prefer set-based SQL (
updateTable(...).set(...).where(...)) over per-row loops. - If a per-row loop is unavoidable, batch by primary key range. Avoid
OFFSET-based pagination — it scans previously-seen rows on every page. - Long-running transactions can hit platform timeouts and hold locks. For very large backfills, consider splitting the work into steps or across multiple migrations, each operating on a subset.
- Add
LIMITand resumability (idempotentwhereclauses) so a re-run after a transient failure converges.
Migrations that take longer than a minute
Migration scripts run as workflow jobs, so they are not bound by the 60-second limit that applies to synchronous script execution. tailor deploy waits for the migration and reports its logs as usual. Execution time is still bounded — a workflow job has its own execution-time limit.
A main script still runs in a single transaction, so the guidance above about locks and transaction size continues to apply. When a migration's work does not fit one job, split it into steps: each step runs as its own job and transaction.
Testing Migrations Locally
Unit-testing migrate.ts
main is a plain function, so you can unit-test it with Vitest before the first deploy ever runs it. createKyselyMock from @tailor-platform/sdk/vitest compiles queries to the same SQL as the deployed migration, so a test verifies the exact statements the script issues — SQL, parameters, and order. Type the mock with the Database interface exported from the generated db.ts.
Scaffold a ready-to-fill test next to the script with:
bash
tailor tailordb migration script 0005 --with-testWhen migrate.ts already exists (the usual case for breaking changes, where migration generate creates it), the command adds only the tests that do not exist yet, plus a missing db.pglite.ts. Or write the test by hand:
typescript
// migrations/0005/migrate.test.ts
import { createKyselyMock } from "@tailor-platform/sdk/vitest";
import { describe, expect, test } from "vitest";
import { main } from "./migrate";
import type { Database } from "./db";
describe("0005 add required email", () => {
test("backfills null emails", async () => {
const mock = createKyselyMock<Database>();
await mock.withTx((trx) => main(trx));
expect(mock.updates).toHaveLength(1);
expect(mock.updates[0]?.updateValues()).toEqual({ email: "unknown@example.com" });
expect(mock.updates[0]?.sql).toContain('where "email" is null');
});
});Stage the rows each query returns with mock.enqueueResult(...) or mock.setQueryResolver(...) when the script reads before writing; call main(trx, { env: { ... } }) when the script takes a MigrationContext. See Kysely-layer mock for the full mock API.
These tests need no platform connection and no tailor-runtime environment — they run in a plain Vitest setup. Vitest's default include pattern already picks up migrations/**/migrate.test.ts; if your config narrows include, add the migrations directory. The test file is ignored by tailor deploy and never ships to the platform.
A migration generated under defaultDateRepresentation "temporal" or "date" records that in its diff.json, and deploy runs the script with Temporal or Date values for t.date(), t.datetime(), and t.time() fields that omit as. The scaffold calls applyDateRepresentation from @tailor-platform/sdk/vitest with the recorded value, or with "string" for a migration that records none, and imports migrate.ts after that call, so the test runs the script the same way deploy does even after the setting changes in tailor.config.ts. Keep that call when editing the test. The record pins the values at run time only: the types of t fields declared inside migrate.ts follow the current tailor.d.ts like everywhere else, so give such fields an explicit as to keep their types and values aligned after the setting changes. The helper selects the representation but does not provide Temporal itself, so run such a test in the tailor-runtime Vitest environment, which installs it on Node.js versions without native Temporal; this is the one case where a migration unit test needs that environment (a migration recorded under "date", or without a record, needs no Temporal and runs in a plain Vitest setup).
Executing migrate.ts against a local Postgres (PGlite)
A statement-level test verifies what the script issues, not what it does to data (e.g., whether a where clause matches the rows you intended). To run main against real rows locally, back Kysely with @electric-sql/pglite — an in-memory PostgreSQL — via createKyselyPGlite from @tailor-platform/sdk/vitest:
bash
npm install -D @electric-sql/pgliteThe generated db.pglite.ts exports the CREATE TABLE script for the same schema db.ts types — the tables as the pre-migration phase leaves them while migrate.ts runs, including relaxed constraints, renamed fields under both names, and retained removed fields. Run it once on the PGlite instance, stage rows, then run the script in a transaction. tailor tailordb migration script <N> --with-test scaffolds this test too when @electric-sql/pglite is installed. Type the instance with Unmigrated<Database> rather than Database: db.ts types a column the migration makes required as T | null on read but T on write (and an enum it narrows as the old values on read but the new ones on write), so that migrate.ts cannot write what the migration is removing — which would also stop the test from staging the rows the script has to convert. Unmigrated lets every column be written with whatever it can still be read as; main still receives a Transaction<Database>.
Relations are enforced as on the platform: a row that points at a parent that does not exist is rejected. Write the records the test starts from inside prepareFixtures, which leaves relations unchecked while it runs, so you can stage rows that predate a relation, such as rows whose parent does not exist that the script repairs. Relations are checked again when it returns, so the script itself still cannot write a missing parent. The same holds in reverse: the script cannot delete a parent that a row still points at, or change the key a row points at, so delete rows in the opposite order of staging them. NOT NULL, UNIQUE, and CHECK constraints stay enforced. A relation is left unenforced while the pre-migration schema cannot satisfy it: when a pending type change leaves the field a different type from its target, when the migration adds the relation to an existing top-level field or retargets it to another table, or when the target field's unique constraint is still withheld. How relations on array fields and inside nested objects behave is described under Real SQL execution with PGlite.
typescript
// migrations/0005/migrate.pglite.test.ts
import { PGlite } from "@electric-sql/pglite";
import { createKyselyPGlite, prepareFixtures, type Unmigrated } from "@tailor-platform/sdk/vitest";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { pgliteSchema } from "./db.pglite";
import { main } from "./migrate";
import type { Database } from "./db";
const pglite = new PGlite();
const db = createKyselyPGlite<Unmigrated<Database>>(pglite);
// PGlite loads Postgres on first use, which can take longer than the default hook timeout.
beforeAll(async () => {
await pglite.exec(pgliteSchema.tailordb);
}, 60_000);
afterAll(async () => {
await db.destroy();
});
describe("0005 add required email", () => {
test("backfills null emails and keeps existing ones", async () => {
const now = new Date();
await prepareFixtures(pglite, async () => {
await db
.insertInto("User")
.values([
{ name: "a", email: null, createdAt: now, updatedAt: now },
{ name: "b", email: "b@example.com", createdAt: now, updatedAt: now },
])
.execute();
});
await db.transaction().execute((trx) => main(trx));
const rows = await db.selectFrom("User").select(["name", "email"]).orderBy("name").execute();
expect(rows).toEqual([
{ name: "a", email: "unknown@example.com" },
{ name: "b", email: "b@example.com" },
]);
});
});Queries on db wait while the transaction is open, so check the results after transaction().execute() returns, or query through trx inside it — a query on db from inside the transaction waits on itself and never finishes.
Pass nested field values as JavaScript objects or arrays of objects, without JSON.stringify.
When the migration was generated with kyselyTypePlugin({ temporal: true }), its db.ts types date and datetime fields as Temporal.PlainDate and Temporal.Instant, and top-level time fields as Temporal.PlainTime; nested time fields remain strings. Deploy runs the script with those values. Create the instance with createKyselyPGlite<Unmigrated<Database>>(pglite, { temporal: true }) so PGlite reads top-level temporal columns back the same way; the scaffold adds the option for such migrations. Run the test in the tailor-runtime Vitest environment, which provides Temporal. A migration generated before temporal was enabled still runs with Date values on deploy, so its test leaves the option out. As in the Kysely mock scaffold, the test calls applyDateRepresentation with the migration's recorded defaultDateRepresentation (or "string") so t date fields parse the way they do on deploy; a migration recorded under "temporal" likewise needs the tailor-runtime environment for Temporal. Generated migration types use Record<string, unknown> for each nested object so scripts can work with both old and new members during a migration; narrow member values before using them.
Two caveats keep this from replacing a scratch workspace:
- PGlite runs full PostgreSQL, while TailorDB supports a subset of it — a statement that passes here can still be rejected on deploy.
db.pglite.tsmirrors the column shape, not the platform: hooks, validations, and permissions do not run, and the limits listed under Real SQL execution with PGlite apply.
Testing a multi-step script
runMigrationSteps from @tailor-platform/sdk/vitest runs a script's steps in the order tailor deploy runs them, each through the transaction callback you pass, so a test can check what a failing step leaves behind. When a step throws, it rejects with a MigrationStepError naming the failed step and the steps that completed before it. With pglite and Database set up as in the PGlite test above:
typescript
import {
createKyselyPGlite,
runMigrationSteps,
type Unmigrated,
} from "@tailor-platform/sdk/vitest";
import { steps } from "./migrate";
const db = createKyselyPGlite<Unmigrated<Database>>(pglite);
test("backfills every table", async () => {
await runMigrationSteps(steps, {
transaction: (run) => db.transaction().execute(run),
env: { STAGE: "test" },
});
// assert the rows
});tailor tailordb migration script <N> --with-test writes both test scaffolds against steps when migrate.ts exports steps, calling runMigrationSteps instead of main.
To test one step on its own, call its run with a transaction — and a { env } context when the step takes one: await db.transaction().execute((trx) => steps.backfillInvoice.run(trx)). With createKyselyMock, pass transaction: (run) => mock.withTx(run).
Beyond unit tests
A unit test verifies which statements the script issues; a PGlite test verifies what they do to the rows you staged. Neither runs against your actual data. To cover that:
- Run
migration generateon a clean working copy first, reviewdiff.json, then run again after editing tables to ensure the diff matches what you intended. - For non-trivial migrations, apply against a scratch workspace before promoting to staging or production.
Environment-Specific Strategies
A migration script is a function — branching on environment (e.g., to skip a backfill in dev) is just normal TypeScript. For environment awareness, use the env values defined in defineConfig({ env }), exposed via the optional second argument { env }: MigrationContext (see Migration Script Anatomy). These are resolved at deploy time and inlined into the bundle. Do not read process.env inside migrate.ts — Node-side environment access is unavailable at runtime; only the injected env (and the data itself) reflect the target environment.
For genuinely different schemas across environments, prefer separate workspaces with the same migration history rather than divergent migrations/ directories.
Troubleshooting
MIGRATION_DIRECTORY_CONFLICT error
Cause: Two or more TailorDB namespaces set migration.directory to the same directory (after resolving it against the config file's directory). For example, tailordb and analyticsdb both set "./migrations". Both namespaces would read and write the same 0000/schema.json, so migration generate could not tell them apart and migration generate --init would rebuild the history of both. Every tailor tailordb migration command and tailor deploy fail with this error before touching any migration files. The error lists every shared directory with the namespaces that use it.
Resolution: Give each namespace its own directory in tailor.config.ts, for example "./migrations/tailordb" and "./migrations/analyticsdb".
Remote schema drift detected
Cause: Remote schema doesn't match what the migration history says it should be. When tailor deploy shows Remote migration: <unset>, or migration validate shows no checkpoint yet, the namespace has no migration label yet and its tables differ from 0000; see Adding migrations to an existing project instead of the steps below.
Resolution:
tailor tailordb migration statusto see local vs remote.- Compare with teammates — has someone applied different migrations?
- If remote was changed manually, decide whether to update the local tables to match and run
migration generate, or to return the remote to a snapshot as in the next step. - To force the remote schema back to a known snapshot, use
migration sync <N>(seemigration syncSemantics). If the drift follows a failed deploy that warned it could not restore a table's schema, see Failure Recovery instead. - As a last resort in non-production environments,
--no-schema-checkskips both checks. Do not use this as a routine workaround. It does not skip the checkpoint check described next.
"Remote migration checkpoint is not in the local migration history" error
Cause: The deployed namespace's checkpoint refers to a migration number, or a migration history ID, that the local migrations/ directory no longer has a record of. This is a different code path from schema drift above: it fires before any schema comparison, because the CLI cannot even locate the remote's recorded position in the local history. tailor deploy stops with this error even with --no-schema-check, so a deploy from an outdated checkout never moves the checkpoint back or overwrites the tables with an older schema.
One specific cause is a migration rebaseline run on a different environment while this one had not yet caught up to the latest pre-rebaseline migration. migration rebaseline requires every other environment to already be at the latest migration (see Re-baselining a deployed migration history), but the CLI only verifies the one workspace it runs against. If this environment was behind, the error message names the migration this environment must reach (the migration every environment was required to be at before the rebaseline) alongside the migration it is actually at.
Resolution (fell behind before a rebaseline):
- Restore the pre-rebaseline
migrations/directory from git history — check out the commit beforemigration rebaselineran. Re-baselining removes migration files after0000from the working tree but Git history retains everything that was committed. - Deploy this environment against that restored history until its checkpoint reaches the migration named in the error, running any
migrate.tsscripts it still needs. - Switch back to the current (rebaselined) migration files and deploy again. The remote schema now matches the new baseline, so this deploy offers the automatic checkpoint reset to
0000under the new history ID.
Resolution (any other cause): Run tailor tailordb migration status to compare local and remote, and pull the latest migration files if your checkout is stale. To move the checkpoint back to the local migration history on purpose, run tailor tailordb migration sync <N> with the latest local migration as <N> (the error suggests the command). It also rewrites the tables to snapshot <N>, removing the fields that snapshot does not have and deleting the tables it does not have together with their data. When the namespace records another migration history ID, or one that cannot be read, the command also needs --force, which replaces that history ID with the local one, or removes it when the local history has none (see migration sync Semantics). Pass --force only when the local migration history should replace the deployed one; otherwise work from a checkout of the migration history deployed to the workspace. Then deploy again.
MIGRATION_FILES_INVALID error
Cause: The migrations/ directory of a namespace does not form a valid history: a migration number is missing, 0000 has no schema.json or has a diff.json, or a later migration has a schema.json or no diff.json. A deleted directory or a merge that kept only part of a migration is a common cause. tailor deploy and the tailor tailordb migration commands that read the history report this error, listing every problem.
Resolution: Follow the suggestion for each problem in the error. Otherwise restore the migration files from version control. If no workspace has been deployed with these migrations, you can instead recreate 0000 from the local tables with tailor tailordb migration generate --init --namespace <name>, which deletes every migration of the namespace.
"Invalid schema snapshot" or "Invalid migration diff" error
Cause: A schema.json or diff.json file in the migrations/ directory is corrupted or does not match the expected structure. Merge conflicts left in these files are a common cause.
Resolution:
- Read the error message — it includes the file path and the offending field.
- Restore the file from version control (
git checkout -- <path>);migration generatereads every migration file and stops with the same error until then. If the broken file belongs to the latest migration and no environment has deployed it, you can instead recreate that migration: copy out the files you edited in its directory (such asmigrate.tsand its tests), delete the directory, runmigration generatewith the options that created it (such as--data-only,--rename, or--drop), copy your edits back, and record any--no-scriptreason again withmigration script. A migration pair created with--expand-contractcannot be recreated this way, so restore its files from version control. - Do not hand-edit
schema.jsonordiff.json; they are managed by the CLI.
"Unsupported migration file format version" error
Cause: A schema.json or diff.json file is older or newer than the format versions supported by the installed SDK.
Resolution: Follow the ordering in the error message. For an older history, restore an SDK version that can read every file, run migration rebaseline, commit and deploy the new baseline everywhere, and then upgrade. For a file produced by a newer SDK, upgrade the SDK that is reading it. Do not hand-edit the version field.
"No machine user available for migration execution"
Cause: Neither migration.machineUser is set nor are there any machine users in auth.machineUsers. tailor deploy reports this while planning, including with --dry-run, before anything is applied, and tailor tailordb migration validate reports it too.
Resolution: Add a machine user to auth.machineUsers, then run tailor deploy again. The deploy creates the machine user before it runs the migration scripts.
"Machine user for migration execution is not defined in auth"
Cause: migration.machineUser, or --machine-user of tailor tailordb migration test, names a machine user that is not in auth.machineUsers. tailor deploy reports this while planning, including with --dry-run, before anything is applied, and tailor tailordb migration validate reports it too.
Resolution: Either add the machine user to auth.machineUsers, or name one that is defined there.
"Machine user not found"
Cause: In tailor tailordb migration test, the machine user that runs the seed or the assertion (from --machine-user, the seed plugin's machineUserName, or migration.machineUser) is not in auth.machineUsers, so the temporary workspace does not have it.
Resolution: Either add the machine user to auth.machineUsers, or name one that is defined there.
Migration script execution fails
Cause: Runtime error in your migrate.ts, a permission error from the machine user, or a constraint violation when post-migration tightens tables.
Resolution: Read the Logs: block. Fix the script or the data assumption it relies on, and re-run tailor deploy. The label is not bumped on failure, so the same migration retries. For a script that exports steps, the logs of each step are prefixed with its name, and the error names the failed step; the retry resumes from that step (see Splitting a migration into steps).
migrate.ts not found for a migration that needs one
Cause: diff.requiresMigrationScript is true but migrate.ts is missing from the migration directory.
Resolution: Restore the file from version control, or create it with tailor tailordb migration script <N> --namespace <namespace>. If the migration intentionally needs no data transformation, record that decision with tailor tailordb migration script <N> --namespace <namespace> --no-script --reason "<why no data migration is needed>" instead.