db/migrate.ts

Identity-tracked database migration runner.

Migrations are named {name, up} objects in ordered arrays, grouped by namespace. A schema_version table records one row per applied migration — (namespace, name, sequence, applied_at) — and the runner verifies the applied list is a name-prefix of the code's migration array at boot.

Schema is not stabilized yet — append-only is NOT the rule. While fuz_app is pre-stable, migration bodies, names, and positions can change freely between versions; consumers upgrading across a schema change are expected to drop and re-bootstrap their dev/test databases (production deployments are not yet a supported use case). Once the schema is declared stable a hard append-only-after-publish rule will apply and the cliff will be called out in that release's notes; until then, body edits to a published migration slip past the runner (no content hashing) by design — they're the recommended way to evolve the schema.

Chain-level transactions: All pending migrations in a namespace run in a single transaction. Any failure rolls back every migration in that run — no partial-state recovery. This rules out non-transactional DDL (e.g., CREATE INDEX CONCURRENTLY); run those out of band.

Chain idempotency, not migration idempotency: the chain-tx wraps every migration replayed in a single boot, so an individual migration may temporarily produce intermediate state that a later migration reverses (e.g. v0's ROLE_GRANT_INDEXES recreates an index that v1 drops; chain-tx hides this from observers). What matters is that the *committed end state* matches; the in-tx steps may not be individually idempotent against an arbitrary mid-chain target.

Forward-only: No down-migrations. Schema changes are additive.

Advisory locking: Per-namespace pg_advisory_lock reduces contention in multi-instance deployments — best-effort, not load-bearing. The locks are session-scoped, but Db.query runs against a pool that may check out a different backend per call, so two concurrent boots can both "hold" the lock on different sessions. The real serialization comes from chain- tx atomicity + the (namespace, name) PK on schema_version: the loser's INSERT hits a PK violation, the chain-tx rolls back, and the next boot reads the committed state and proceeds cleanly. Environments without pg_advisory_lock (some PGlite versions) silently fall through.

Declarations
#

8 declarations

view source

baseline
#

db/migrate.ts view source

(db: Db, ns: MigrationNamespace, names: readonly string[]): Promise<void>

Insert tracker rows for the named migrations of a namespace without executing them.

Used to promote an existing schema (e.g. produced by a pre-0.42 build, preserved through a tracker-shape upgrade) into the new identity tracker. baseline() trusts the operator-supplied list — it does not verify that the schema actually matches what the named migrations would have produced. Pair with a schema-assertion script post-baseline before re-enabling traffic.

Contract: - Probes for the pre-0.42 tracker shape; throws old-tracker-shape if found (DDL with IF NOT EXISTS would otherwise no-op against the old table and the INSERT would fail with a confusing column-not-found). - Creates the new-shape schema_version table if missing — cutover scripts that just dropped the old-shape table can call baseline() directly with no separate DDL step. - Acquires the same per-namespace advisory lock as run_migrations (with the same try/catch fallback for environments lacking pg_advisory_lock). - Refuses if any tracker rows already exist *for this namespace* — lets multi-call baseline scripts resume after partial failure (completed namespaces guard themselves while remaining ones still run). - Verifies the supplied names are a strict prefix of the namespace's current migrations array — a name not in the array, or out of order, errors before any INSERT. - Writes sequences 0..N-1 in one transaction.

db

the database instance

type Db

ns

the namespace whose migrations are being baselined

names

prefix of ns.migrations[].name to record as already-applied

type readonly string[]

returns

Promise<void>

throws

  • MigrationError - with `kind` of `old-tracker-shape`,

Migration
#

db/migrate.ts view source

Migration

A single migration: a name + an up function applied inside a transaction.

Throw from up to roll back the entire chain.

name

type string

up

type (db: Db) => Promise<void>

MigrationError
#

db/migrate.ts view source

Tagged error thrown by run_migrations and baseline.

Branch on .kind; the message carries an operator-facing remediation hint.

inheritance

extends:
  • Error

kind

type MigrationErrorKind

readonly

namespace

type string

readonly

at_index

type number

readonly

unknown_names

type ReadonlyArray<string>

readonly

constructor

type new (kind: MigrationErrorKind, message: string, context?: MigrationErrorContext | undefined): MigrationError

kind
message
type string
context?
type MigrationErrorContext | undefined
optional

MigrationErrorContext
#

db/migrate.ts view source

MigrationErrorContext

Structured context passed alongside a MigrationError.

namespace

type string

at_index

type number

unknown_names

type ReadonlyArray<string>

cause

type unknown

MigrationErrorKind
#

MigrationNamespace
#

db/migrate.ts view source

MigrationNamespace

A named group of ordered migrations.

Array index = position in the chain. Pre-stable: bodies, names, and positions can change between versions (consumers re-bootstrap on upgrade).

namespace

type string

migrations

type Array<Migration>

MigrationResult
#

db/migrate.ts view source

MigrationResult

Result of running migrations for a single namespace.

namespace

type string

applied_names

Migrations applied in this run, in sequence-ascending (execution) order.

type Array<string>

run_migrations
#

db/migrate.ts view source

(db: Db, namespaces: MigrationNamespace[]): Promise<MigrationResult[]>

Run pending migrations for each namespace.

For each namespace: acquires an advisory lock, reads applied rows ordered by sequence, length-checks (binary-older-than-db short-circuits), name- prefix-verifies, then runs the pending tail in a single chain transaction. Each migration's row is INSERTed with sequence = max(sequence) + 1 for the namespace.

Length check before name verify is load-bearing: a binary-older case with a rename in the overlap would otherwise fire name-divergence-at-N first and the operator would chase a phantom source-revert before discovering the binary is the real problem.

Atomicity: any failure rolls back every migration that ran in that invocation. Namespaces are independent: a later namespace's failure does not roll back an earlier namespace that already committed.

Concurrency: per-namespace advisory locks reduce contention in multi-instance deployments but are best-effort on pool drivers (see the module docstring's "Advisory locking" notes). Correctness on concurrent boots falls out of chain-tx atomicity + the (namespace, name) PK — the loser's INSERT triggers PK violation and rollback; subsequent boots see the committed state.

db

the database instance

type Db

namespaces

migration namespaces, processed in the order passed

type MigrationNamespace[]

returns

Promise<MigrationResult[]>

one result per namespace where work happened (already-up-to-date namespaces are omitted)

throws

  • MigrationError - with `kind` of `binary-older-than-db`,

Imported by
#