Database migration safety
Reconcile Drizzle migration files, metadata, and applied database state without destroying a developer database.
Nautilo uses Drizzle migration files as committed history and
drizzle.__drizzle_migrations as the database record of what actually ran.
Those states are related, but they are not interchangeable. A clean Git merge
does not prove that a long-lived local database can safely run the merged
migration sequence.
This decision tree is for developers synchronizing branches or resolving a migration collision. It does not depend on Nautilo's internal planning system.
Treat the migration record as one system
Inspect all four parts:
packages/db/src/migrations/NNNN_<name>.sql;packages/db/src/migrations/meta/_journal.json;packages/db/src/migrations/meta/NNNN_snapshot.json; and- the target database's
drizzle.__drizzle_migrationsrows.
drizzle-kit generate compares the TypeScript schema with the last committed
snapshot, not with the live database. drizzle-kit migrate uses the committed
migration files and the database ledger to determine what should run.
Renaming a file is not reconciliation
Never resolve a collision by changing migration filenames alone.
Before synchronizing a schema branch
Answer this first:
Has this branch's incoming migration already been applied to the database I intend to keep?
If the answer is unknown, stop. Inspect the SQL, journal, snapshot chain, database ledger, and resulting schema before choosing a repair. Do not infer the answer from whether the application currently starts.
The incoming migration has not been applied
Regenerate it on top of the updated parent history:
-
Back up the incoming SQL so you can compare its intent later.
-
Use the parent branch's SQL, journal, and snapshot chain as the base.
-
Remove the unapplied generated migration and its metadata from the incoming side.
-
Regenerate against the merged schema:
bun run --cwd packages/db db:generate --name <descriptive-name> -
Compare the regenerated SQL with the backup.
-
Stop if the regenerated migration is empty or materially changes the intended DDL. The updated parent may already implement the change or may invalidate the original assumption.
-
Apply the result to an isolated local instance and validate the chain:
bun run --cwd packages/db db:check
Regeneration is the normal path because it creates the next migration from the new parent snapshot.
The incoming migration has already been applied
Do not regenerate it and do not edit its SQL body. The retained database has already executed that DDL.
- Rename the incoming SQL to the next free migration number.
- Keep its SQL body byte-for-byte unchanged.
- Move its journal entry to the new contiguous
idx; keep tags unique. - Chain its snapshot after the new current parent snapshot.
- Validate the repository migration chain.
- Separately inspect the retained database's
drizzle.__drizzle_migrationsrow before runningdb:migrateagain.
The repository can now be coherent while the retained database still records the old journal timestamp and hash relationship. Repairing that ledger or resetting/restoring the database is a separate operator decision. Neither is safe to guess or automate.
Both branches changed the same schema intent
Do not mechanically retain both migrations. Compare the resulting schema and decide whether one migration supersedes the other, a new reconciliation migration is required, or the incoming change should be dropped. An empty regenerated migration is evidence to investigate, not permission to silently delete work.
The shared default database is already divergent
Stop repeatedly running dev-stack; it invokes db:migrate again and will
fail the same way.
Preserve the database, inspect the exact SQL hashes and schema shape, and choose one explicitly:
- reconcile the local ledger only when the applied SQL is proven identical and the operator approves;
- restore a known-good backup; or
- leave that database intact and use an isolated named instance whose starting
migration lineage is known. A clone of
defaultpreserves its ledger; a fresh instance proves the migration chain from an empty database. Choose the one that answers the actual question.
Never reset, rewrite, or directly edit the default database ledger as an automatic conflict-resolution step.
Forbidden shortcuts
- Do not use
drizzle-kit pushorbun db:push; Nautilo uses generated, reviewed migrations. - Do not edit or delete the SQL body of an already-applied migration.
- Do not mutate
drizzle.__drizzle_migrationswithout explicit approval. - Do not run a repair script without reading it and proving that it covers the current migrations.
- Do not assume matching migration numbers mean matching SQL.
- Do not assume a successful application boot means migration history is coherent.
Validate before continuing
bun run --cwd packages/db db:check
python3 -m json.tool packages/db/src/migrations/meta/_journal.json >/dev/nullAlso verify:
- migration numbers and tags are unique;
- journal
idxvalues are contiguous; - every snapshot points to the intended previous snapshot;
- no conflict markers remain in SQL or metadata;
- regenerated SQL still implements the reviewed schema intent; and
- focused database tests and typecheck pass.
Only then apply the result to an isolated instance. Upgrade testing against a
long-lived default database should be deliberate and backed up.
For the isolated and retained-instance run paths, see Run Nautilo from source.
Related scenario
See Reconcile a migration without sacrificing the developer database for the same decision as an outcome-led workflow with explicit failure and recovery boundaries.
Test a source change
Run focused checks, protect the retained default database, and finish with product-level acceptance when the change needs it.
Nautilo entity model
A five-minute orientation to the private Server, people, machine people, Rooms, policy, tools, and runtime boundaries that make Nautilo work.