-
Notifications
You must be signed in to change notification settings - Fork 2
NORM Migrations
Schema migrations for norm, derived from your entity definitions with
no hand-written SQL. The Migrator snapshots your schema, diffs it
into reviewable per-dialect DDL, and applies it under a lock, with
drift detection, a table-rebuild engine, and drop guards.
- The model
- Importing the Migrator
- The workflow
- Reviewable stored plans
- Safety gates
- Renames
- The rebuild engine
- Foreign key referential actions
- Dialect notes
- API reference
- Related documentation
Migrations are state-based. You never write a migration. Each version
is a full physical snapshot of your schema, a numbered JSON file
(0001.json, 0002.json, …). The migration is the diff between two
consecutive snapshots, and "down" is the reverse diff.
migrations/
├── 0001.json ← full schema state at v1
├── 0001.sqlite.sql ← reviewable DDL for SQLite
├── 0001.postgres.sql ← reviewable DDL for PostgreSQL
├── 0001.maria.sql ← reviewable DDL for MariaDB/MySQL
├── 0002.json ← full schema state at v2
├── 0002.sqlite.sql
├── …
└── migrator.lock ← single-host mutex (gitignore this)
A snapshot records only physical facts: table and column names, types,
primary keys, foreign keys, indexes, uniques, and the crypto markers
(encrypt, hash, hashed). Logical concerns (validators, hooks,
defaults, scopes, masks, and terminal QUERY entities) never appear,
because they generate no DDL. Encrypted columns snapshot as their
at-rest shape (TEXT), since that is what lands in the table.
Filenames are zero-padded sequence numbers, not timestamps. Two
branches that both mint 0003.json collide on the filename in git,
which is the alarm you want rather than two "latest" migrations
silently coexisting.
Applied migrations are recorded in a reserved table,
_norm_migrations, whose own schema is fixed and never itself
migrates. Do not register a model under that name. Each row carries the
version, the snapshot's hash, when it was applied, by whom, and how
long it took.
A second reserved table, _norm_migration_progress, appears only on
engines without transactional DDL (MariaDB, MongoDB, and the fetch-only
dialects) and holds the resume checkpoint for a version that failed
halfway. It is emptied as soon as the version is recorded; see
What happens when a statement fails halfway.
Do not register a model under that name either.
Every snapshot carries a 64-bit FNV-1a rollup hash (16 hex chars) over
its canonical, key-sorted JSON. rename hints are excluded from the
hash, so a hinted snapshot and its steady-state equivalent hash
identically. On apply, that hash is stored alongside the version; on
every later run it is recomputed and compared. A mismatch means the
applied snapshot file was edited or deleted after it was applied. That
is drift, and the Migrator refuses to proceed rather than diff against
a version that no longer means what the database recorded.
The Migrator lives on its own subpath and is not re-exported from the package root. Migrations are an operational concern, kept out of the request path and out of your app bundle.
import '@tundralibs/norm/engines/sqlite';
import { Column, Entity, Norm, Schema } from '@tundralibs/norm';
import { Migrator } from '@tundralibs/norm/migrations';
const Users = Entity('users', {
id: Column.integer(),
email: Column.varchar(255).encrypt().hash(),
displayName: Column.varchar(120).nullable(),
}, {
pk: ['id'],
unique: { email: ['email_hash'] },
});
const norm = new Norm({
database: { dialect: 'sqlite', path: './data' },
secret: process.env.SECRET,
});
const db = norm.use(Schema('App', { Users }));
// The Migrator binds to the handle returned by norm.use(...).
const mig = new Migrator(db, { dir: './migrations' });The everyday loop is snapshot, review, apply:
await mig.snapshot(); // write 0001.json (.sql is opt-in — see below)
await mig.plan(); // inspect the DDL each pending version would run
await mig.apply(); // execute it + record in _norm_migrationsWrites the next versioned snapshot, unless the current schema is
hash-identical to the head snapshot, in which case it is a no-op. It
writes the 000N.json file only. The reviewable 000N.<dialect>.sql
plan artifacts are opt-in: construct the Migrator with
{ renderSql: true } to have snapshot() emit them too, or generate
them on demand with renderPlans(); see
Reviewable stored plans.
const s = await mig.snapshot();
// { version: 1, path: './migrations/0001.json', written: true }
// Nothing changed since the last snapshot → no new files.
const again = await mig.snapshot();
// { version: 1, path: './migrations/0001.json', written: false }written: false tells you the schema is unchanged; commit your
definitions and move on.
Compares the filesystem against the database: what is applied, what is pending, and whether the applied head still matches its recorded hash.
const st = await mig.status();
// { dbVersion: 1, fsVersion: 3, pending: [2, 3], hashOk: true }-
dbVersion: highest applied version (0= fresh database). -
fsVersion: highest snapshot on disk (0= none yet). -
pending: versions on disk but not yet applied, ascending. -
hashOk:falsesignals drift. The applied head snapshot no longer matches the hash recorded when it was applied (edited or deleted).
Returns the DDL each pending version would run, oldest first. Inspect this before you apply. It has no side effects and nothing touches the database.
const steps = await mig.plan();
for (const step of steps) {
console.log(`v${step.version}`);
console.log(' actions:', step.queries.length);
console.log(' blocked drops:', step.blockedDrops);
console.log(' warnings:', step.warnings);
}Each PlannedStep carries the ordered queries (DDL actions, plus any
table rebuilds), the blockedDrops an allowDrop: false run would
refuse, and warnings for apply-time hazards (see
Safety gates). Pass { allowDrop: true } to see the
plan with drops emitted rather than blocked.
plan() throws if the applied head snapshot is missing from the
directory, since pending diffs would baseline against the wrong
version, or if a step needs an ALTER the dialect cannot express.
Executes the pending plan and records each version in
_norm_migrations. Status and plan are computed inside the lock, so a
plan can never go stale while waiting on a concurrent run.
const r = await mig.apply();
// { applied: [2, 3], durationMs: 41, warnings: [] }warnings is always populated (a real apply, not only dryRun) — the
same apply-time hazards plan()'s PlannedStep.warnings surfaces (see
above), plus anything only known once DDL actually runs (e.g. a table
REBUILD_TABLE needed on SQLite). One concrete case: a FK crossing a
dbSchema boundary on SQLite, or any FK on MongoDB — neither dialect
can physically enforce it, so the constraint is skipped (best-effort,
never thrown) and named here; the relation itself still works for
joins/eager projection. See Referential actions.
apply() refuses, before running anything, when:
- the applied head has drifted (
hashOk: false), - a step contains blocked drops (
allowDropisfalse; see below), - a stored plan artifact's hash no longer matches the plan this apply would execute (see Reviewable stored plans).
Options:
await mig.apply({
allowDrop: true, // emit DROP TABLE/COLUMN (default false)
appliedBy: 'ci-runner', // audit column; defaults to $USER/$USERNAME
lockTimeoutMs: 60_000, // lock acquire timeout (default 30_000)
});
apply()androllback()run DDL only; neither touches norm's read cache. If theNorminstance you migrate against also hascacheconfigured, calldb.clearCache()after applying so rows cached under the old shape do not linger, especially on an external engine (Redis/Memcached) that outlives the process.
A version's plan is many statements. What a mid-plan failure leaves behind depends on whether the engine's DDL participates in transactions, which is not the same question as whether it supports transactions at all:
| Dialect | DDL in a transaction? | Guarantee on mid-plan failure |
|---|---|---|
| Postgres | yes | Atomic rollback. Nothing is applied; retry freely. |
| SQLite | yes | Atomic rollback. Nothing is applied; retry freely. |
| MariaDB | no, DDL auto-commits | Safe retry. Completed statements stay; the retry resumes after them. |
| Mongo | no transactions | Safe retry. Same resume mechanism. |
| Neon / Turso / D1 | no transactions | Safe retry. Same resume mechanism. |
MariaDB/MySQL report transactions: true and mean it, for DML. Every
DDL statement issues an implicit commit, so a BEGIN around a
migration there buys nothing. norm therefore exposes a separate
transactionalDdl capability and picks per engine.
On Postgres and SQLite the version's DDL and its _norm_migrations
row commit as one transaction. Statement k failing rolls back
statements 1 to _k_−1 with it, the version is never recorded, and the
database is byte-identical to where it started. CREATE_SCHEMA — which
never appears in a SQLite plan at all; see
SQLite dbSchema — is the
one exception on Postgres: it runs before the transaction, idempotent
(IF NOT EXISTS), so a retry is unaffected.
On MariaDB, MongoDB, and the fetch-only dialects atomicity is
impossible, so the retry is made safe instead. After each action lands,
norm records how far the version got in _norm_migration_progress; the
next apply() resumes from that point rather than re-emitting
statements that already succeeded. This matters because ADD COLUMN
and ADD CONSTRAINT are emitted without IF NOT EXISTS, so re-running
them fails with "already exists"; before the checkpoint existed the
only recovery was dropping objects by hand.
The checkpoint is fingerprinted with a hash of the action list. If the
snapshots change while a version sits half-applied, the resume would
skip the wrong statements, so apply() refuses with PLAN_CHANGED
instead of guessing. Reconcile the schema by hand, delete the row from
_norm_migration_progress, and re-run. Checkpoint rows are deleted the
moment a version is recorded, so the table is empty on a healthy
database and is never created at all on Postgres and SQLite.
Not covered: a table rebuild (see The rebuild engine) that dies mid-flight on a non-transactional dialect. Its
__pre_migratetable survives and the next apply refuses on the rename collision. That recovery is manual, by design.
Dry run. Compute and return the full plan without executing or recording anything; drift is still checked:
const dry = await mig.apply({ dryRun: true });
// { applied: [], durationMs: 2, warnings: [], plannedQueries: [ …PlannedStep… ] }
console.log(dry.plannedQueries![0].blockedDrops);On the transactional-DDL engines (PostgreSQL, SQLite) a version's DDL
and its _norm_migrations row commit as one transaction (see above).
That transaction would otherwise inherit the driver's request-scale
auto-rollback timer, 120s by default, which is far too short for a
large chunked crypto rebuild and would force-roll it back mid-copy. The
Migrator therefore disarms that timer by default, so a version may run
as long as it needs. Set transactionTimeoutMs on the constructor to
re-impose a ceiling:
const mig = new Migrator(db, {
dir: './migrations',
transactionTimeoutMs: 0, // default — disarmed, no per-version cap
// transactionTimeoutMs: 600_000, // re-impose a 10-minute ceiling
});-
Default
0disarms the cap entirely, the correct setting for a multi-minute rebuild that must not be interrupted. Passing0explicitly means the same as omitting the option (as does any non-positive value); it is never clamped up to a 1-second cap. - A positive value re-imposes a ceiling, rounded up to whole seconds (the driver's unit), never below 1s. Raise it above the driver's 120s default only to bound a legitimately long version, or set a low value to fail a runaway migration fast.
- It has no effect on MariaDB/MySQL, MongoDB, or the fetch-only dialects, whose DDL cannot be wrapped in a transaction; they use the checkpoint-resume path instead.
Replays the reverse diff back down to a target version, deleting the
reverted rows from _norm_migrations. Rolling back a CREATE is a
DROP, so drops are implied and always allowed here; that is the point
of a rollback.
await mig.rollback({ to: 2 }); // revert down to v2
// { reverted: [4, 3], durationMs: 18 }
await mig.rollback(); // default: one version back (dbVersion − 1)The target must be below the applied head. rollback({ to: 5 }) when
the head is 3 throws a NormMigrationError, as does a rollback whose
intermediate snapshot file has gone missing.
Each reverted version runs under the same per-dialect atomicity rules
as apply(): atomic on
Postgres and SQLite, checkpoint-resumable elsewhere.
Returns the applied migrations, newest first.
const hist = await mig.history();
for (const h of hist) {
console.log(h.version, h.appliedAt, h.appliedBy, `${h.durationMs}ms`);
}
// [{ version: 3, hash: '…', appliedAt: '2026-…', appliedBy: 'ci', durationMs: 12 }, …]Regenerates the 000N.<dialect>.sql artifacts for every snapshot on
disk. Use it after hand-editing definitions, or to repair an artifact
that went missing or was tampered with.
const out = await mig.renderPlans();
// [{ version: 1, files: ['…/0001.sqlite.sql', '…/0001.postgres.sql', '…/0001.maria.sql'] }, …]With renderSql: true, every snapshot() writes one
000N.<dialect>.sql artifact per SQL dialect (sqlite, postgres,
maria) next to the JSON; renderPlans() writes them on demand
otherwise. These are review material: they go through your PR process
exactly as hand-written migrations would, and once they exist they are
enforced.
Each artifact opens with a plan-hash header:
-- norm migration plan v0001 — dialect: postgres
-- plan-hash: 6f3a1c9d2b47e058
-- REVIEW ARTIFACT. apply() recomputes this dialect's plan and
-- REFUSES when its hash differs from the line above. Regenerate
-- with Migrator.renderPlans() after editing definitions.
CREATE TABLE "users" ( … );
CREATE UNIQUE INDEX "ux_users_email" ON "users" ("email_hash");At apply time, the Migrator recomputes its own dialect's plan for the version, hashes the executable statements (FNV-1a over the statement list, comments excluded), and compares it to the stored header. If the hashes differ, apply refuses:
Plan artifact 0001.postgres.sql does not match the plan this apply would execute … the snapshot or artifact changed after review.
The guarantee: what runs in production is exactly what was reviewed,
regardless of who regenerated what in between. The gate is active only
once an artifact is on disk. With no stored artifact, apply()
executes the freshly computed plan, since SQL plans are opt-in (see the
MISSING_ARTIFACT caveat).
Artifacts always render with allowDrop: true so reviewers see every
drop a version implies; this is independent of the apply-time
allowDrop gate. Rebuild steps render their real DDL bracket (rename
aside, create, drop aside) with the copy and verification steps shown
as comments, because those run through per-row JS rather than SQL.
MongoDB has no SQL surface, so it produces no artifacts and skips the
check.
Every destructive or risky change is surfaced, never skipped.
allowDrop defaults to false. Any DROP TABLE or DROP COLUMN the
plan would emit is instead collected into blockedDrops, as entity
or entity.column, and apply() refuses rather than recording the
version with the drop quietly skipped, which would permanently desync
the database from the snapshot chain:
const dry = await mig.apply({ dryRun: true });
// dry.plannedQueries[0].blockedDrops → ['Users.fullName']
await mig.apply();
// throws: "apply refused: drops are blocked (allowDrop: false) —
// v3: Users.fullName. Pass allowDrop: true, or add
// renamedFrom hints if these are renames."
await mig.apply({ allowDrop: true }); // explicit opt-in runs the dropsA dropped column is very often a forgotten rename, hence the hint in the message (see Renames).
Dropping a column from a table with an
audit replica drops it from the source as normal,
still gated by allowDrop and still blocked by default. The replica
never loses it. Instead the column is retired: renamed to _<column>_,
and relaxed to nullable if it was NOT NULL, since the source will
never supply a value for it again and a NOT NULL retired column would
fail every future write. This happens automatically and
unconditionally, not gated by allowDrop, since nothing is destroyed:
// Users.legacyNote is NOT NULL and is being removed from the source.
const [step] = await mig.plan();
// step.blockedDrops → ['Users.legacyNote'] (the SOURCE drop)
// step.warnings → ["UserAudit.legacyNote: removed from the source —
// retired (not dropped) in the audit replica as '_legacyNote_' so
// its history survives; it will read as null on every version
// from here on."]
await mig.apply({ allowDrop: true }); // drops Users.legacyNote;
// UserAudit's copy survives as `_legacyNote_`, nullable, with every
// historical value intact.This preserves the data. It does not keep it queryable through norm:
db.repo('UserAudit').find(...) never mentions _legacyNote_. The
replica's type is rebuilt from the source's current columns every time
your app starts, the same mechanism that makes add, rename, and
type-change propagate to the replica for free, so a name that no longer
exists on the source is invisible to it too. The values are real,
physical, and intact in the database; reaching them means db.raw()
or db.query(), not find():
await db.raw('SELECT "_legacyNote_" FROM user_audit WHERE "id" = :id:', { id });A retired name is reusable exactly once: if a column named legacyNote
is re-added to the source later, it mirrors normally under its own name
while the frozen _legacyNote_ keeps the old history untouched.
Retiring the same name a second time collides with the still-frozen
column from the first retirement, so plan and apply fall back to an
ordinary, blocked-by-default drop in that case rather than overwriting
it.
norm never emits DDL column defaults (defaults are system-generated at
write time), so adding a NOT NULL column fails on a populated table.
The diff cannot know the row count, so it always warns:
const [step] = await mig.plan();
// step.warnings → ["Users.age: adding a NOT NULL column will fail if
// 'users' has rows — make it nullable() and backfill, then tighten…"]The fix: add the column as .nullable(), backfill it, then tighten in
a later migration. Nullability changes need PostgreSQL or MariaDB, or a
rebuild on SQLite.
apply() and rollback() take two locks so migrations can never run
concurrently:
- A file lock,
migrator.lockin the migrations directory (gitignore it), serializes two processes on the same host. On SQLite, where the database is a local file, this is the whole story. - A server-side advisory lock,
pg_advisory_lockon PostgreSQL andGET_LOCKon MariaDB/MySQL, serializes across machines, so two CI runners deploying from different replicas cannot migrate at once. Dialects without one (SQLite, and the fetch-only Neon, Turso, and D1) skip it.
The plan is computed inside both locks: a plan computed before waiting on a concurrent apply would be stale by the time it ran. If the advisory lock is already held elsewhere, apply throws rather than blocking forever:
Another process holds the migration advisory lock ('norm:migrator') — is a deploy running elsewhere?
Lock acquisition honors lockTimeoutMs (default 30s). Both locks are
released on every exit path, including that one: a host whose advisory
lock times out does not leave its migrator.lock behind.
migrator.lock carries its owner and the time it was last stamped, and
a live apply() re-stamps it between versions. A lock file that has
gone untouched for lockStaleMs (default 15 minutes) is treated as
abandoned, after a killed pod, an OOM, or a kill -9, and the next
contender reclaims it instead of waiting forever:
const mig = new Migrator(db, {
dir: './migrations',
lockStaleMs: 60 * 60_000, // an hour, for very long rebuilds
// lockStaleMs: Infinity // never reclaim; delete by hand
});Because the stamp is refreshed between versions, the TTL only has to
outlast the slowest single step, not the whole run. A
crypto-transforming rebuild re-stamps the lock
after every chunk (rebuildChunkSize rows), so it can safely run past
lockStaleMs. A structural rebuild's single INSERT … SELECT copy (no
crypto change) has no mid-flight checkpoint, though: if that one
statement runs longer than lockStaleMs on a huge table, raise the
value for it. A lock file written by an older norm (bare token, no
stamp) falls back to the file's mtime.
Renames are hint-driven only. The diff engine runs no heuristics, so it never guesses that a dropped-plus-added pair is a rename.
Add renamedFrom to the new column. The diff pairs it to the old
column and emits a RENAME COLUMN, so data survives, instead of a
drop-plus-add:
import { Column, Entity } from '@tundralibs/norm';
const Users = Entity('users', {
id: Column.integer(),
fullName: Column.varchar(120).nullable().renamedFrom('displayName'),
}, { pk: ['id'] });const [step] = await mig.plan();
// the ALTER carries: renameColumns: { displayName: 'fullName' }An entity's registry key is its identity. Change only the physical
table name under a stable key and the diff recognizes a rename with no
hint required: it emits RENAME TO and re-creates any indexes whose
names embed the table name:
import { Column, Entity } from '@tundralibs/norm';
// key stays 'Folks'; physical name folks → people
const Folks = Entity('people', {
id: Column.integer(),
tag: Column.varchar(20).nullable(),
}, { pk: ['id'], index: { byTag: ['tag'] } });
// plan: ALTER TABLE folks RENAME TO people;
// DROP INDEX ix_folks_byTag; CREATE INDEX ix_people_byTag …If instead you rename the entity key but keep the same physical table,
the Migrator matches the two by physical identity and it costs zero
DDL, a pure registry re-key. Use renamedFrom on the entity when the
physical table name changes and you cannot rely on a stable key.
Some changes cannot be expressed as an in-place ALTER. For those, the
Migrator emits a composite REBUILD_TABLE action. Two categories
trigger it:
- On every dialect, a crypto-marker flip (
encryptorhashturned on or off), because the data itself must be rewritten: decrypt, re-encrypt, or backfill digest siblings. - On dialects without in-place ALTER (SQLite, Turso, D1): type, nullability, primary-key, or foreign-key changes. Primary-key changes force a rebuild on all dialects.
The rebuild runs as an ordered sequence, keeping the original data safe until the very end:
drop old indexes
→ rename table aside (<name>__pre_migrate)
→ create the new shape (+ its indexes)
→ copy rows
→ verify row counts (copied === original, else throw)
→ drop the aside table
The copy step has two forms:
-
Structural (no crypto change): one
INSERT … SELECTcopies every column pair in a single statement. -
Crypto-transforming: rows are streamed through JS in pages,
decrypting what was encrypted, encrypting what is, and backfilling
<col>_hashdigest siblings from the recovered plaintext. This is chunked byrebuildChunkSize(default 500) and paged in primary-key order, so a multi-million-row table never materializes in memory:
const mig = new Migrator(db, {
dir: './migrations',
rebuildChunkSize: 1000, // rows per page/INSERT during a crypto rebuild
});A non-positive rebuildChunkSize is floored to 1, since a zero-row
page would never advance the copy, and a non-finite value (NaN,
Infinity) falls back to the default 500.
A crypto-transforming rebuild rewrites encrypted data, so a secret
must be configured on the Norm instance; the rebuild throws if it is
absent or empty.
The row-count verification runs before the aside table is dropped. If
the counts disagree, the rebuild throws and leaves the original in
place as <name>__pre_migrate for recovery.
-
MariaDB is not crash-safe here. MariaDB/MySQL implicitly commit on
DDL, so a crash mid-rebuild leaves
<name>__pre_migratebehind. That is deliberate: the next apply fails loudly on the rename collision rather than guessing, and you recover the aside table manually. -
Digest-algorithm changes are rejected. Changing a one-way digest's
algorithm (
Column.hash('SHA-256')toColumn.hash('SHA-512'), say) throws at diff time, since a one-way digest has no plaintext to re-digest from. Add a new column and backfill from source data instead.
onDelete and onUpdate declared on a foreign key are physical facts:
they participate in the drift hash and are emitted into the generated
DDL, inline in CREATE TABLE on a fresh table or as an
ADD CONSTRAINT when added to an existing one.
import { Column, Entity } from '@tundralibs/norm';
const Profiles = Entity('profiles', {
userId: Column.integer(),
}, {
pk: ['userId'],
fk: {
User: {
model: 'Users', // the registry key, never a table name
on: { userId: 'id' },
onDelete: 'CASCADE',
onUpdate: 'NO_ACTION',
},
},
});
// Generated DDL (all three SQL dialects) includes:
// … REFERENCES users (id) ON DELETE CASCADE ON UPDATE NO ACTIONBecause the actions are hashed, changing only an action (CASCADE to
RESTRICT, say) still moves the drift hash and produces a new
migration.
| Concern | PostgreSQL | MariaDB/MySQL | SQLite | MongoDB |
|---|---|---|---|---|
| In-place ALTER | ✅ | ✅ | ❌¹ | — |
| Table rebuild engine | crypto² | crypto² | ✅ | — |
| Advisory lock | ✅ | ✅ | ❌³ | — |
| Transactional DDL | ✅ | ❌⁷ | ✅ | ❌⁷ |
| Plan artifacts | ✅ | ✅ | ✅ | ❌⁴ |
Named dbSchema
|
✅⁶ | ✅⁶ | ✅⁶ | — |
| Migrator used at all | ✅ | ✅ | ✅ |
¹ SQLite cannot alter column types, nullability, primary keys, or
foreign keys in place, so those changes go through the rebuild engine.
² PostgreSQL and MariaDB alter structural changes in place; they only
rebuild for crypto-marker flips (a data rewrite). ³ SQLite is a local
file, so the migrator.lock file lock is sufficient. ⁴ MongoDB has no
SQL surface, so no reviewable .sql is rendered and the artifact check
is skipped. ⁵ MongoDB is schemaless, so the Migrator does not own its
schema. Create indexes directly against the collection; do not run the
Migrator against a Mongo engine. ⁶ An entity's dbSchema names a real
namespace the Migrator provisions before any table placed in it on
PostgreSQL (CREATE SCHEMA) and MariaDB/MySQL (CREATE DATABASE,
where a schema is a database); SQLite has no such namespace to
provision — see SQLite dbSchema
below. ⁷ MariaDB/MySQL implicitly COMMIT on every DDL statement and
Mongo has no transaction surface, so a version's plan cannot be atomic
there; apply() checkpoints per action and resumes on retry instead.
See What happens when a statement fails halfway.
The fetch-only dialects follow their base column: Neon behaves as
PostgreSQL for in-place ALTER, the rebuild engine, and plan artifacts,
and Turso and D1 behave as SQLite, dbSchema included — the same
translator, with no engine-specific file handling to differ. All three
lack the advisory lock and transactional DDL, so they take the
checkpoint-resume path⁷.
SQLite (and Turso/D1, which speak its SQL) has no schema object at
all: CREATE_SCHEMA / DROP_SCHEMA never appear in a SQLite plan,
because there is nothing to provision. Instead, an entity's dbSchema
is folded into its physical table (and index) name as a
<dbSchema>_<name> prefix — Entity('Account', {...}, { dbSchema: 'UserGroup' }) becomes the physical table UserGroup_Account — so
every "schema" lives in the same physical file as everything else. A FK
crossing a dbSchema boundary is therefore an ordinary, physically
enforced constraint on SQLite, not something the Migrator has to skip
(contrast Referential actions,
where MongoDB — the one dialect that really can't enforce a FK — still
does).
new Migrator(db: object, options: {
dir: string; // migrations directory (snapshots + lock)
rebuildChunkSize?: number; // rows per page in a crypto rebuild (500)
renderSql?: boolean; // write reviewable .sql artifacts (false)
lockStaleMs?: number; // abandoned-lock reclaim age (900_000)
transactionTimeoutMs?: number; // per-version tx cap, Postgres/SQLite
// (0 = disarmed, the default)
});
snapshot(): Promise<{ version: number; path: string; written: boolean }>;
status(): Promise<{
dbVersion: number;
fsVersion: number;
pending: ReadonlyArray<number>;
hashOk: boolean;
}>;
plan(opts?: { allowDrop?: boolean }): Promise<Array<{
version: number;
queries: ReadonlyArray<MigrationAction>;
blockedDrops: ReadonlyArray<string>;
warnings: ReadonlyArray<string>;
}>>;
apply(opts?: {
allowDrop?: boolean; // default false
appliedBy?: string; // default $USER / $USERNAME
lockTimeoutMs?: number; // default 30_000
dryRun?: boolean;
}): Promise<{
applied: ReadonlyArray<number>;
durationMs: number;
warnings: ReadonlyArray<string>; // always populated, not just dryRun
plannedQueries?: ReadonlyArray<PlannedStep>; // dryRun only
}>;
rollback(opts?: { to?: number; lockTimeoutMs?: number }): Promise<{
reverted: ReadonlyArray<number>;
durationMs: number;
}>;
history(): Promise<Array<{
version: number;
hash: string;
appliedAt: string;
appliedBy: string | null;
durationMs: number | null;
}>>;
renderPlans(): Promise<ReadonlyArray<{
version: number;
files: ReadonlyArray<string>;
}>>;Every failure surfaces as a NormMigrationError (from
@tundralibs/norm/errors), carrying the directory and version in
context.
-
Schema definition: columns, entities, relations,
renamedFrom, and the crypto markers migrations diff on. - Audit tables: the generated replica whose dropped columns this migrator retires instead of dropping.
-
Temporal tables: migrates as an ordinary TABLE;
EffectiveFrom/EffectiveToare physical columns diffed like any other, with no migration-specific handling. -
Read caching: why
db.clearCache()is a manual step afterapply()/rollback(). - Security: encryption and digest siblings, whose flips drive the rebuild engine.
- Querying: the typed read/write surface the migrated schema serves.