Skip to content

NORM Migrations

GitHub Actions edited this page Sep 13, 2026 · 11 revisions

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.

Deno Bun Node.js

Table of Contents

The model

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.

The tracking table and drift hash

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.

Importing the Migrator

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 workflow

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_migrations

snapshot()

Writes 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.

status()

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: false signals drift. The applied head snapshot no longer matches the hash recorded when it was applied (edited or deleted).

plan()

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.

apply()

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 (allowDrop is false; 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() and rollback() run DDL only; neither touches norm's read cache. If the Norm instance you migrate against also has cache configured, call db.clearCache() after applying so rows cached under the old shape do not linger, especially on an external engine (Redis/Memcached) that outlives the process.

What happens when a statement fails halfway

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_migrate table 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);

Per-version transaction timeout

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 0 disarms the cap entirely, the correct setting for a multi-minute rebuild that must not be interrupted. Passing 0 explicitly 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.

rollback()

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.

history()

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 }, …]

renderPlans()

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'] }, …]

Reviewable stored plans

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.

Safety gates

Every destructive or risky change is surfaced, never skipped.

Blocked drops

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 drops

A dropped column is very often a forgotten rename, hence the hint in the message (see Renames).

Audit replicas never drop a column

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.

NOT NULL warnings

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.

The two locks

apply() and rollback() take two locks so migrations can never run concurrently:

  1. A file lock, migrator.lock in 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.
  2. A server-side advisory lock, pg_advisory_lock on PostgreSQL and GET_LOCK on 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.

Stale lock reclaim

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

Renames are hint-driven only. The diff engine runs no heuristics, so it never guesses that a dropped-plus-added pair is a rename.

Column renames

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' }

Table renames

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.

The rebuild engine

Some changes cannot be expressed as an in-place ALTER. For those, the Migrator emits a composite REBUILD_TABLE action. Two categories trigger it:

  1. On every dialect, a crypto-marker flip (encrypt or hash turned on or off), because the data itself must be rewritten: decrypt, re-encrypt, or backfill digest siblings.
  2. 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 … SELECT copies 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>_hash digest siblings from the recovered plaintext. This is chunked by rebuildChunkSize (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.

Caveats

  • MariaDB is not crash-safe here. MariaDB/MySQL implicitly commit on DDL, so a crash mid-rebuild leaves <name>__pre_migrate behind. 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') to Column.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.

Foreign key referential actions

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 ACTION

Because the actions are hashed, changing only an action (CASCADE to RESTRICT, say) still moves the drift hash and produces a new migration.

Dialect notes

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 dbSchema is a physical name prefix

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).

API reference

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.

Related documentation

  • 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/EffectiveTo are physical columns diffed like any other, with no migration-specific handling.
  • Read caching: why db.clearCache() is a manual step after apply()/rollback().
  • Security: encryption and digest siblings, whose flips drive the rebuild engine.
  • Querying: the typed read/write surface the migrated schema serves.

← Back to NORM

Clone this wiki locally