@fuzdev/fuz_app 0.41.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,59 @@
1
1
  /**
2
- * Version-gated database migration runner.
2
+ * Identity-tracked database migration runner.
3
3
  *
4
- * Migrations are functions in ordered arrays, grouped by namespace.
5
- * A `schema_version` table tracks progress per namespace.
4
+ * Migrations are named `{name, up}` objects in ordered arrays, grouped by
5
+ * namespace. A `schema_version` table records one row per applied migration —
6
+ * `(namespace, name, sequence, applied_at)` — and the runner verifies the
7
+ * applied list is a name-prefix of the code's migration array at boot.
6
8
  *
7
- * **Chain-level transactions**: All pending migrations in a namespace run in a
8
- * single transaction. Any failure rolls back every migration in that run —
9
+ * **Append-only after first publish**: once a fuz_app version containing a
10
+ * given migration is published (`npm publish` / `jsr publish`), that
11
+ * migration's name and position are frozen. Never edit, rename, or reorder
12
+ * after publish — append only. Pre-publish, anything goes; the cliff is the
13
+ * publish event. Edits to a published migration's body slip past the runner
14
+ * (no content hashing) and are caught by schema-snapshot tests in consumers.
15
+ *
16
+ * **Chain-level transactions**: All pending migrations in a namespace run in
17
+ * a single transaction. Any failure rolls back every migration in that run —
9
18
  * no partial-state recovery. This rules out non-transactional DDL (e.g.,
10
19
  * `CREATE INDEX CONCURRENTLY`); run those out of band.
11
20
  *
12
- * **Forward-only**: No down-migrations. Schema changes are additive.
13
- * For pre-release development, collapse migrations into a single v0.
21
+ * **Chain idempotency, not migration idempotency**: the chain-tx wraps every
22
+ * migration replayed in a single boot, so an individual migration may
23
+ * temporarily produce intermediate state that a later migration reverses
24
+ * (e.g. v0's `PERMIT_INDEXES` recreates an index that v1 drops; chain-tx
25
+ * hides this from observers). What matters is that the *committed end state*
26
+ * matches; the in-tx steps may not be individually idempotent against an
27
+ * arbitrary mid-chain target.
14
28
  *
15
- * **Named migrations**: Migrations can be bare functions or `{name, up}` objects.
16
- * Names appear in error messages for debuggability.
29
+ * **Forward-only**: No down-migrations. Schema changes are additive.
17
30
  *
18
- * **Advisory locking**: Per-namespace PostgreSQL advisory locks serialize
19
- * concurrent migration runs, preventing double-application in multi-instance deployments.
31
+ * **Advisory locking**: Per-namespace `pg_advisory_lock` reduces contention
32
+ * in multi-instance deployments best-effort, not load-bearing. The locks
33
+ * are session-scoped, but `Db.query` runs against a pool that may check out
34
+ * a different backend per call, so two concurrent boots can both "hold"
35
+ * the lock on different sessions. The real serialization comes from chain-
36
+ * tx atomicity + the `(namespace, name)` PK on `schema_version`: the
37
+ * loser's INSERT hits a PK violation, the chain-tx rolls back, and the
38
+ * next boot reads the committed state and proceeds cleanly. Environments
39
+ * without `pg_advisory_lock` (some PGlite versions) silently fall through.
20
40
  *
21
41
  * @module
22
42
  */
23
43
  import type { Db } from './db.js';
24
44
  /**
25
- * A single migration function that receives a `Db` and applies DDL/DML.
26
- *
27
- * Runs inside a transaction — throw to rollback.
28
- */
29
- export type MigrationFn = (db: Db) => Promise<void>;
30
- /**
31
- * A migration: either a bare function or a named object with an `up` function.
45
+ * A single migration: a name + an `up` function applied inside a transaction.
32
46
  *
33
- * Named migrations include their name in error messages for debuggability.
47
+ * Throw from `up` to roll back the entire chain.
34
48
  */
35
- export type Migration = MigrationFn | {
49
+ export interface Migration {
36
50
  name: string;
37
- up: MigrationFn;
38
- };
51
+ up: (db: Db) => Promise<void>;
52
+ }
39
53
  /**
40
54
  * A named group of ordered migrations.
41
55
  *
42
- * Array index = version number: `migrations[0]` is version 0, etc.
56
+ * Array index = position in the chain. Append-only after publish.
43
57
  */
44
58
  export interface MigrationNamespace {
45
59
  namespace: string;
@@ -48,30 +62,101 @@ export interface MigrationNamespace {
48
62
  /** Result of running migrations for a single namespace. */
49
63
  export interface MigrationResult {
50
64
  namespace: string;
51
- from_version: number;
52
- to_version: number;
53
- migrations_applied: number;
65
+ /** Migrations applied in this run, in sequence-ascending (execution) order. */
66
+ applied_names: Array<string>;
67
+ }
68
+ /**
69
+ * Tagged error vocabulary for {@link run_migrations} and {@link baseline}.
70
+ *
71
+ * Callers branch on `.kind` rather than matching error messages — message
72
+ * text is for operators, not control flow.
73
+ */
74
+ export type MigrationErrorKind = 'binary-older-than-db' | 'name-divergence-at-N' | 'old-tracker-shape' | 'migration-failed' | 'baseline-name-not-in-code' | 'baseline-name-out-of-order' | 'baseline-namespace-already-populated';
75
+ /** Structured context passed alongside a {@link MigrationError}. */
76
+ export interface MigrationErrorContext {
77
+ namespace?: string;
78
+ at_index?: number;
79
+ unknown_names?: ReadonlyArray<string>;
80
+ cause?: unknown;
81
+ }
82
+ /**
83
+ * Tagged error thrown by {@link run_migrations} and {@link baseline}.
84
+ *
85
+ * Branch on `.kind`; the message carries an operator-facing remediation hint.
86
+ */
87
+ export declare class MigrationError extends Error {
88
+ readonly kind: MigrationErrorKind;
89
+ readonly namespace?: string;
90
+ readonly at_index?: number;
91
+ readonly unknown_names?: ReadonlyArray<string>;
92
+ constructor(kind: MigrationErrorKind, message: string, context?: MigrationErrorContext);
54
93
  }
55
94
  /**
56
95
  * Run pending migrations for each namespace.
57
96
  *
58
- * Creates the `schema_version` tracking table if it does not exist,
59
- * then for each namespace: acquires an advisory lock, reads the current
60
- * version, runs all pending migrations in order inside a single transaction,
61
- * updates the stored version, and releases the lock.
97
+ * For each namespace: acquires an advisory lock, reads applied rows ordered
98
+ * by `sequence`, length-checks (binary-older-than-db short-circuits), name-
99
+ * prefix-verifies, then runs the pending tail in a single chain transaction.
100
+ * Each migration's row is INSERTed with `sequence = max(sequence) + 1` for
101
+ * the namespace.
102
+ *
103
+ * **Length check before name verify** is load-bearing: a binary-older case
104
+ * with a rename in the overlap would otherwise fire `name-divergence-at-N`
105
+ * first and the operator would chase a phantom source-revert before
106
+ * discovering the binary is the real problem.
62
107
  *
63
- * **Atomicity**: The pending chain for each namespace runs in one transaction —
64
- * any failure rolls back every migration that ran in that invocation. The
65
- * next run starts from the previously-stored version, re-running the whole
66
- * (fixed) chain. Namespaces are independent: a later namespace's failure
67
- * does not roll back an earlier namespace that already committed.
108
+ * **Atomicity**: any failure rolls back every migration that ran in that
109
+ * invocation. Namespaces are independent: a later namespace's failure does
110
+ * not roll back an earlier namespace that already committed.
68
111
  *
69
- * **Concurrency**: Uses PostgreSQL advisory locks to serialize concurrent
70
- * callers on the same namespace. Safe for multi-instance deployments.
112
+ * **Concurrency**: per-namespace advisory locks reduce contention in
113
+ * multi-instance deployments but are best-effort on pool drivers (see
114
+ * module docstring §Advisory locking). Correctness on concurrent boots
115
+ * falls out of chain-tx atomicity + the `(namespace, name)` PK — the
116
+ * loser's INSERT triggers PK violation and rollback; subsequent boots
117
+ * see the committed state.
71
118
  *
72
119
  * @param db - the database instance
73
- * @param namespaces - migration namespaces to process in order
74
- * @returns results per namespace (only includes namespaces that had work to do)
120
+ * @param namespaces - migration namespaces, processed in the order passed
121
+ * @returns one result per namespace where work happened (already-up-to-date
122
+ * namespaces are omitted)
123
+ * @throws MigrationError with `kind` of `binary-older-than-db`,
124
+ * `name-divergence-at-N`, `old-tracker-shape`, or `migration-failed`
75
125
  */
76
126
  export declare const run_migrations: (db: Db, namespaces: Array<MigrationNamespace>) => Promise<Array<MigrationResult>>;
127
+ /**
128
+ * Insert tracker rows for the named migrations of a namespace **without
129
+ * executing them**.
130
+ *
131
+ * Used to promote an existing schema (e.g. produced by a pre-0.42 build,
132
+ * preserved through a tracker-shape upgrade) into the new identity tracker.
133
+ * `baseline()` trusts the operator-supplied list — it does not verify that
134
+ * the schema actually matches what the named migrations would have produced.
135
+ * Pair with a schema-assertion script post-baseline before re-enabling traffic.
136
+ *
137
+ * Contract:
138
+ * - Probes for the pre-0.42 tracker shape; throws `old-tracker-shape` if
139
+ * found (DDL with `IF NOT EXISTS` would otherwise no-op against the old
140
+ * table and the INSERT would fail with a confusing column-not-found).
141
+ * - Creates the new-shape `schema_version` table if missing — cutover
142
+ * scripts that just dropped the old-shape table can call `baseline()`
143
+ * directly with no separate DDL step.
144
+ * - Acquires the same per-namespace advisory lock as `run_migrations` (with
145
+ * the same try/catch fallback for environments lacking `pg_advisory_lock`).
146
+ * - Refuses if any tracker rows already exist *for this namespace* — lets
147
+ * multi-call baseline scripts resume after partial failure (completed
148
+ * namespaces guard themselves while remaining ones still run).
149
+ * - Verifies the supplied names are a strict prefix of the namespace's
150
+ * current migrations array — a name not in the array, or out of order,
151
+ * errors before any INSERT.
152
+ * - Writes sequences `0..N-1` in one transaction.
153
+ *
154
+ * @param db - the database instance
155
+ * @param ns - the namespace whose migrations are being baselined
156
+ * @param names - prefix of `ns.migrations[].name` to record as already-applied
157
+ * @throws MigrationError with `kind` of `old-tracker-shape`,
158
+ * `baseline-name-not-in-code`, `baseline-name-out-of-order`, or
159
+ * `baseline-namespace-already-populated`
160
+ */
161
+ export declare const baseline: (db: Db, ns: MigrationNamespace, names: ReadonlyArray<string>) => Promise<void>;
77
162
  //# sourceMappingURL=migrate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"migrate.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,SAAS,CAAC;AAEhC;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpD;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,WAAW,CAAA;CAAC,CAAC;AAEtE;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CAC7B;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,CAAC;CAC3B;AA8BD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,cAAc,GAC1B,IAAI,EAAE,EACN,YAAY,KAAK,CAAC,kBAAkB,CAAC,KACnC,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CA0EhC,CAAC"}
1
+ {"version":3,"file":"migrate.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,SAAS,CAAC;AAEhC;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CAC7B;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,aAAa,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAC3B,sBAAsB,GACtB,sBAAsB,GACtB,mBAAmB,GACnB,kBAAkB,GAClB,2BAA2B,GAC3B,4BAA4B,GAC5B,sCAAsC,CAAC;AAE1C,oEAAoE;AACpE,MAAM,WAAW,qBAAqB;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;gBAEnC,IAAI,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,qBAAqB;CAQtF;AA6ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,cAAc,GAC1B,IAAI,EAAE,EACN,YAAY,KAAK,CAAC,kBAAkB,CAAC,KACnC,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAuFhC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,QAAQ,GACpB,IAAI,EAAE,EACN,IAAI,kBAAkB,EACtB,OAAO,aAAa,CAAC,MAAM,CAAC,KAC1B,OAAO,CAAC,IAAI,CA+Dd,CAAC"}
@@ -1,38 +1,91 @@
1
1
  /**
2
- * Version-gated database migration runner.
2
+ * Identity-tracked database migration runner.
3
3
  *
4
- * Migrations are functions in ordered arrays, grouped by namespace.
5
- * A `schema_version` table tracks progress per namespace.
4
+ * Migrations are named `{name, up}` objects in ordered arrays, grouped by
5
+ * namespace. A `schema_version` table records one row per applied migration —
6
+ * `(namespace, name, sequence, applied_at)` — and the runner verifies the
7
+ * applied list is a name-prefix of the code's migration array at boot.
6
8
  *
7
- * **Chain-level transactions**: All pending migrations in a namespace run in a
8
- * single transaction. Any failure rolls back every migration in that run —
9
+ * **Append-only after first publish**: once a fuz_app version containing a
10
+ * given migration is published (`npm publish` / `jsr publish`), that
11
+ * migration's name and position are frozen. Never edit, rename, or reorder
12
+ * after publish — append only. Pre-publish, anything goes; the cliff is the
13
+ * publish event. Edits to a published migration's body slip past the runner
14
+ * (no content hashing) and are caught by schema-snapshot tests in consumers.
15
+ *
16
+ * **Chain-level transactions**: All pending migrations in a namespace run in
17
+ * a single transaction. Any failure rolls back every migration in that run —
9
18
  * no partial-state recovery. This rules out non-transactional DDL (e.g.,
10
19
  * `CREATE INDEX CONCURRENTLY`); run those out of band.
11
20
  *
12
- * **Forward-only**: No down-migrations. Schema changes are additive.
13
- * For pre-release development, collapse migrations into a single v0.
21
+ * **Chain idempotency, not migration idempotency**: the chain-tx wraps every
22
+ * migration replayed in a single boot, so an individual migration may
23
+ * temporarily produce intermediate state that a later migration reverses
24
+ * (e.g. v0's `PERMIT_INDEXES` recreates an index that v1 drops; chain-tx
25
+ * hides this from observers). What matters is that the *committed end state*
26
+ * matches; the in-tx steps may not be individually idempotent against an
27
+ * arbitrary mid-chain target.
14
28
  *
15
- * **Named migrations**: Migrations can be bare functions or `{name, up}` objects.
16
- * Names appear in error messages for debuggability.
29
+ * **Forward-only**: No down-migrations. Schema changes are additive.
17
30
  *
18
- * **Advisory locking**: Per-namespace PostgreSQL advisory locks serialize
19
- * concurrent migration runs, preventing double-application in multi-instance deployments.
31
+ * **Advisory locking**: Per-namespace `pg_advisory_lock` reduces contention
32
+ * in multi-instance deployments best-effort, not load-bearing. The locks
33
+ * are session-scoped, but `Db.query` runs against a pool that may check out
34
+ * a different backend per call, so two concurrent boots can both "hold"
35
+ * the lock on different sessions. The real serialization comes from chain-
36
+ * tx atomicity + the `(namespace, name)` PK on `schema_version`: the
37
+ * loser's INSERT hits a PK violation, the chain-tx rolls back, and the
38
+ * next boot reads the committed state and proceeds cleanly. Environments
39
+ * without `pg_advisory_lock` (some PGlite versions) silently fall through.
20
40
  *
21
41
  * @module
22
42
  */
43
+ /**
44
+ * Tagged error thrown by {@link run_migrations} and {@link baseline}.
45
+ *
46
+ * Branch on `.kind`; the message carries an operator-facing remediation hint.
47
+ */
48
+ export class MigrationError extends Error {
49
+ kind;
50
+ namespace;
51
+ at_index;
52
+ unknown_names;
53
+ constructor(kind, message, context) {
54
+ super(message, context?.cause !== undefined ? { cause: context.cause } : undefined);
55
+ this.name = 'MigrationError';
56
+ this.kind = kind;
57
+ this.namespace = context?.namespace;
58
+ this.at_index = context?.at_index;
59
+ this.unknown_names = context?.unknown_names;
60
+ }
61
+ }
23
62
  const SCHEMA_VERSION_DDL = `
24
63
  CREATE TABLE IF NOT EXISTS schema_version (
25
- namespace TEXT PRIMARY KEY,
26
- version INTEGER NOT NULL DEFAULT 0,
27
- applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
64
+ namespace TEXT NOT NULL,
65
+ name TEXT NOT NULL,
66
+ sequence INTEGER NOT NULL,
67
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
68
+ PRIMARY KEY (namespace, name),
69
+ UNIQUE (namespace, sequence)
28
70
  )`;
29
- /** Normalize a migration to its function and optional name. */
30
- const resolve_migration = (m) => {
31
- if (typeof m === 'function') {
32
- return { fn: m, name: null };
33
- }
34
- return { fn: m.up, name: m.name };
71
+ /**
72
+ * Detect the pre-0.42 `schema_version` shape (`namespace`, `version`,
73
+ * `applied_at`). The new-shape DDL uses `IF NOT EXISTS` and would silently
74
+ * no-op against the old table, so this probe runs before DDL and before any
75
+ * per-namespace lock.
76
+ */
77
+ const detect_old_tracker = async (db) => {
78
+ const row = await db.query_one(`SELECT EXISTS (
79
+ SELECT 1 FROM information_schema.columns
80
+ WHERE table_schema = 'public'
81
+ AND table_name = 'schema_version'
82
+ AND column_name = 'version'
83
+ ) as exists`);
84
+ return row?.exists ?? false;
35
85
  };
86
+ const OLD_TRACKER_HINT = 'Detected fuz_app < 0.42 tracker shape (schema_version.version column exists). ' +
87
+ 'Hint: `DROP TABLE schema_version` and re-run, or call `baseline()` first if ' +
88
+ 'preserving an existing schema.';
36
89
  /**
37
90
  * Compute a stable int32 advisory lock key from a namespace string.
38
91
  *
@@ -45,83 +98,199 @@ const namespace_lock_key = (namespace) => {
45
98
  }
46
99
  return hash;
47
100
  };
101
+ /**
102
+ * Run `fn` with the namespace's advisory lock held.
103
+ *
104
+ * Acquire / release are best-effort and silently fall through in
105
+ * environments without `pg_advisory_lock` (some PGlite versions). The
106
+ * `finally` ensures release on any throw from `fn`.
107
+ */
108
+ const with_namespace_lock = async (db, namespace, fn) => {
109
+ const lock_key = namespace_lock_key(namespace);
110
+ try {
111
+ await db.query('SELECT pg_advisory_lock($1)', [lock_key]);
112
+ }
113
+ catch {
114
+ // Advisory lock not supported — proceed without serialization
115
+ }
116
+ try {
117
+ return await fn();
118
+ }
119
+ finally {
120
+ try {
121
+ await db.query('SELECT pg_advisory_unlock($1)', [lock_key]);
122
+ }
123
+ catch {
124
+ // Advisory lock not supported — nothing to release
125
+ }
126
+ }
127
+ };
48
128
  /**
49
129
  * Run pending migrations for each namespace.
50
130
  *
51
- * Creates the `schema_version` tracking table if it does not exist,
52
- * then for each namespace: acquires an advisory lock, reads the current
53
- * version, runs all pending migrations in order inside a single transaction,
54
- * updates the stored version, and releases the lock.
131
+ * For each namespace: acquires an advisory lock, reads applied rows ordered
132
+ * by `sequence`, length-checks (binary-older-than-db short-circuits), name-
133
+ * prefix-verifies, then runs the pending tail in a single chain transaction.
134
+ * Each migration's row is INSERTed with `sequence = max(sequence) + 1` for
135
+ * the namespace.
136
+ *
137
+ * **Length check before name verify** is load-bearing: a binary-older case
138
+ * with a rename in the overlap would otherwise fire `name-divergence-at-N`
139
+ * first and the operator would chase a phantom source-revert before
140
+ * discovering the binary is the real problem.
55
141
  *
56
- * **Atomicity**: The pending chain for each namespace runs in one transaction —
57
- * any failure rolls back every migration that ran in that invocation. The
58
- * next run starts from the previously-stored version, re-running the whole
59
- * (fixed) chain. Namespaces are independent: a later namespace's failure
60
- * does not roll back an earlier namespace that already committed.
142
+ * **Atomicity**: any failure rolls back every migration that ran in that
143
+ * invocation. Namespaces are independent: a later namespace's failure does
144
+ * not roll back an earlier namespace that already committed.
61
145
  *
62
- * **Concurrency**: Uses PostgreSQL advisory locks to serialize concurrent
63
- * callers on the same namespace. Safe for multi-instance deployments.
146
+ * **Concurrency**: per-namespace advisory locks reduce contention in
147
+ * multi-instance deployments but are best-effort on pool drivers (see
148
+ * module docstring §Advisory locking). Correctness on concurrent boots
149
+ * falls out of chain-tx atomicity + the `(namespace, name)` PK — the
150
+ * loser's INSERT triggers PK violation and rollback; subsequent boots
151
+ * see the committed state.
64
152
  *
65
153
  * @param db - the database instance
66
- * @param namespaces - migration namespaces to process in order
67
- * @returns results per namespace (only includes namespaces that had work to do)
154
+ * @param namespaces - migration namespaces, processed in the order passed
155
+ * @returns one result per namespace where work happened (already-up-to-date
156
+ * namespaces are omitted)
157
+ * @throws MigrationError with `kind` of `binary-older-than-db`,
158
+ * `name-divergence-at-N`, `old-tracker-shape`, or `migration-failed`
68
159
  */
69
160
  export const run_migrations = async (db, namespaces) => {
161
+ if (await detect_old_tracker(db)) {
162
+ throw new MigrationError('old-tracker-shape', OLD_TRACKER_HINT);
163
+ }
70
164
  await db.query(SCHEMA_VERSION_DDL);
71
165
  const results = [];
72
166
  for (const { namespace, migrations } of namespaces) {
73
- const lock_key = namespace_lock_key(namespace);
74
- // Acquire advisory lock serializes concurrent migration runs
75
- try {
76
- await db.query('SELECT pg_advisory_lock($1)', [lock_key]);
77
- }
78
- catch {
79
- // Advisory lock not supported (e.g. some PGlite versions) — proceed without
80
- }
81
- try {
82
- const row = await db.query_one('SELECT version FROM schema_version WHERE namespace = $1', [namespace]);
83
- const current_version = row?.version ?? 0;
84
- if (current_version > migrations.length) {
85
- throw new Error(`schema_version for "${namespace}" is ${current_version} but only ${migrations.length} migrations exist — was a migration removed?`);
167
+ await with_namespace_lock(db, namespace, async () => {
168
+ const applied = await db.query(`SELECT name, sequence FROM schema_version
169
+ WHERE namespace = $1
170
+ ORDER BY sequence ASC`, [namespace]);
171
+ // Step 3: length check (short-circuits before name verify)
172
+ if (applied.length > migrations.length) {
173
+ const unknown_names = applied.slice(migrations.length).map((r) => r.name);
174
+ throw new MigrationError('binary-older-than-db', `Namespace "${namespace}": database has ${applied.length} applied migrations ` +
175
+ `but the code only knows ${migrations.length}. ` +
176
+ `Unknown to this binary: ${unknown_names.map((n) => `"${n}"`).join(', ')}. ` +
177
+ `Hint: upgrade the code to a version that includes these migrations; ` +
178
+ `if you must downgrade, manually delete the unknown rows from schema_version.`, { namespace, unknown_names });
86
179
  }
87
- if (current_version === migrations.length) {
88
- continue; // up to date
180
+ // Step 4: name-prefix verify
181
+ for (let i = 0; i < applied.length; i++) {
182
+ const applied_name = applied[i].name;
183
+ const code_name = migrations[i].name;
184
+ if (applied_name !== code_name) {
185
+ throw new MigrationError('name-divergence-at-N', `Namespace "${namespace}": applied[${i}].name = "${applied_name}" ` +
186
+ `but code[${i}].name = "${code_name}". ` +
187
+ `Hint: the migrations array was reordered or renamed (revert the source ` +
188
+ `change), OR row ${i} or earlier was deleted from the tracker ` +
189
+ `(re-insert the missing row with a sequence value lower than the rows ` +
190
+ `that follow it, or delete from row ${i} onward to re-apply the tail).`, { namespace, at_index: i });
191
+ }
89
192
  }
90
- // run all pending migrations in a single transaction — any failure
91
- // rolls back the whole pending chain
193
+ // Step 5: up-to-date case
194
+ if (applied.length === migrations.length)
195
+ return;
196
+ // Step 6: run pending tail in a single chain-tx
197
+ let next_sequence = applied.length > 0 ? applied[applied.length - 1].sequence + 1 : 0;
198
+ const applied_names = [];
92
199
  await db.transaction(async (tx) => {
93
- for (let i = current_version; i < migrations.length; i++) {
94
- const { fn, name } = resolve_migration(migrations[i]);
95
- const label = name != null ? `"${name}"` : '';
200
+ for (let i = applied.length; i < migrations.length; i++) {
201
+ const m = migrations[i];
96
202
  try {
97
- await fn(tx);
98
- await tx.query(`INSERT INTO schema_version (namespace, version, applied_at)
99
- VALUES ($1, $2, NOW())
100
- ON CONFLICT (namespace)
101
- DO UPDATE SET version = $2, applied_at = NOW()`, [namespace, i + 1]);
203
+ await m.up(tx);
204
+ await tx.query(`INSERT INTO schema_version (namespace, name, sequence)
205
+ VALUES ($1, $2, $3)`, [namespace, m.name, next_sequence]);
206
+ applied_names.push(m.name);
207
+ next_sequence++;
102
208
  }
103
209
  catch (err) {
104
- const name_part = label ? ` ${label}` : '';
105
- throw new Error(`Migration ${namespace}[${i}]${name_part} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
210
+ if (err instanceof MigrationError)
211
+ throw err;
212
+ throw new MigrationError('migration-failed', `Migration ${namespace}["${m.name}"] failed: ` +
213
+ `${err instanceof Error ? err.message : String(err)}. ` +
214
+ `Hint: fix the migration body and retry; the chain is left at the prior committed version.`, { namespace, at_index: i, cause: err });
106
215
  }
107
216
  }
108
217
  });
109
- results.push({
110
- namespace,
111
- from_version: current_version,
112
- to_version: migrations.length,
113
- migrations_applied: migrations.length - current_version,
114
- });
218
+ results.push({ namespace, applied_names });
219
+ });
220
+ }
221
+ return results;
222
+ };
223
+ /**
224
+ * Insert tracker rows for the named migrations of a namespace **without
225
+ * executing them**.
226
+ *
227
+ * Used to promote an existing schema (e.g. produced by a pre-0.42 build,
228
+ * preserved through a tracker-shape upgrade) into the new identity tracker.
229
+ * `baseline()` trusts the operator-supplied list — it does not verify that
230
+ * the schema actually matches what the named migrations would have produced.
231
+ * Pair with a schema-assertion script post-baseline before re-enabling traffic.
232
+ *
233
+ * Contract:
234
+ * - Probes for the pre-0.42 tracker shape; throws `old-tracker-shape` if
235
+ * found (DDL with `IF NOT EXISTS` would otherwise no-op against the old
236
+ * table and the INSERT would fail with a confusing column-not-found).
237
+ * - Creates the new-shape `schema_version` table if missing — cutover
238
+ * scripts that just dropped the old-shape table can call `baseline()`
239
+ * directly with no separate DDL step.
240
+ * - Acquires the same per-namespace advisory lock as `run_migrations` (with
241
+ * the same try/catch fallback for environments lacking `pg_advisory_lock`).
242
+ * - Refuses if any tracker rows already exist *for this namespace* — lets
243
+ * multi-call baseline scripts resume after partial failure (completed
244
+ * namespaces guard themselves while remaining ones still run).
245
+ * - Verifies the supplied names are a strict prefix of the namespace's
246
+ * current migrations array — a name not in the array, or out of order,
247
+ * errors before any INSERT.
248
+ * - Writes sequences `0..N-1` in one transaction.
249
+ *
250
+ * @param db - the database instance
251
+ * @param ns - the namespace whose migrations are being baselined
252
+ * @param names - prefix of `ns.migrations[].name` to record as already-applied
253
+ * @throws MigrationError with `kind` of `old-tracker-shape`,
254
+ * `baseline-name-not-in-code`, `baseline-name-out-of-order`, or
255
+ * `baseline-namespace-already-populated`
256
+ */
257
+ export const baseline = async (db, ns, names) => {
258
+ if (await detect_old_tracker(db)) {
259
+ throw new MigrationError('old-tracker-shape', OLD_TRACKER_HINT, { namespace: ns.namespace });
260
+ }
261
+ await db.query(SCHEMA_VERSION_DDL);
262
+ const code_names = ns.migrations.map((m) => m.name);
263
+ // Validate every supplied name exists in code (catches drift between cutover
264
+ // script and deployed build before any INSERT).
265
+ for (const name of names) {
266
+ if (!code_names.includes(name)) {
267
+ throw new MigrationError('baseline-name-not-in-code', `baseline: name "${name}" is not in namespace "${ns.namespace}" migrations. ` +
268
+ `Hint: confirm the deployed fuz_app version matches what the cutover script ` +
269
+ `was written against — name drift between build and script is the most common cause.`, { namespace: ns.namespace });
115
270
  }
116
- finally {
117
- // Release advisory lock
118
- try {
119
- await db.query('SELECT pg_advisory_unlock($1)', [lock_key]);
120
- }
121
- catch {
122
- // Advisory lock not supported nothing to release
123
- }
271
+ }
272
+ // Validate names are a strict prefix of code_names (catches reordering).
273
+ for (let i = 0; i < names.length; i++) {
274
+ if (names[i] !== code_names[i]) {
275
+ throw new MigrationError('baseline-name-out-of-order', `baseline: namespace "${ns.namespace}" supplied names are not a prefix of ` +
276
+ `the code's migrations. At position ${i}: supplied "${names[i]}" but code ` +
277
+ `expects "${code_names[i]}". Hint: re-order the supplied names to match ` +
278
+ `the code's array order.`, { namespace: ns.namespace, at_index: i });
124
279
  }
125
280
  }
126
- return results;
281
+ await with_namespace_lock(db, ns.namespace, async () => {
282
+ const existing = await db.query('SELECT name FROM schema_version WHERE namespace = $1 LIMIT 1', [ns.namespace]);
283
+ if (existing.length > 0) {
284
+ throw new MigrationError('baseline-namespace-already-populated', `baseline: namespace "${ns.namespace}" already has tracker rows. ` +
285
+ `Hint: per-namespace guard for partial-failure resume — completed namespaces ` +
286
+ `self-skip; if you need to re-baseline, manually ` +
287
+ `\`DELETE FROM schema_version WHERE namespace = '${ns.namespace}'\` first.`, { namespace: ns.namespace });
288
+ }
289
+ await db.transaction(async (tx) => {
290
+ for (let i = 0; i < names.length; i++) {
291
+ await tx.query(`INSERT INTO schema_version (namespace, name, sequence)
292
+ VALUES ($1, $2, $3)`, [ns.namespace, names[i], i]);
293
+ }
294
+ });
295
+ });
127
296
  };
@@ -13,11 +13,11 @@ import type { MigrationNamespace } from './migrate.js';
13
13
  */
14
14
  export interface MigrationStatus {
15
15
  namespace: string;
16
- /** Current applied version (0 if never migrated). */
17
- current_version: number;
18
- /** Total available migrations in the namespace. */
19
- available_version: number;
20
- /** Whether the schema is up to date. */
16
+ /** Names of migrations recorded in the tracker, sequence-ascending. */
17
+ applied_names: Array<string>;
18
+ /** Names of code migrations not yet applied (suffix of the code array). */
19
+ pending_names: Array<string>;
20
+ /** Whether `applied_names` is the full code array (no pending work). */
21
21
  up_to_date: boolean;
22
22
  }
23
23
  /**
@@ -41,14 +41,20 @@ export interface DbStatus {
41
41
  tables: Array<TableStatus>;
42
42
  /** Per-namespace migration status. */
43
43
  migrations: Array<MigrationStatus>;
44
+ /**
45
+ * True if the pre-0.42 `schema_version` shape (with a `version` column)
46
+ * was detected. The runner refuses to start in this state — operators
47
+ * see this flag as their cue to drop the table or call `baseline()`.
48
+ */
49
+ old_tracker_shape?: boolean;
44
50
  }
45
51
  /**
46
- * Query database status including connectivity, tables, and migration versions.
52
+ * Query database status including connectivity, tables, and migration state.
47
53
  *
48
54
  * Designed for CLI `db:status` commands. Does not modify the database.
49
55
  *
50
56
  * @param db - the database instance
51
- * @param namespaces - migration namespaces to check versions for
57
+ * @param namespaces - migration namespaces to check status for
52
58
  * @returns a snapshot of database status
53
59
  */
54
60
  export declare const query_db_status: (db: Db, namespaces?: Array<MigrationNamespace>) => Promise<DbStatus>;
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/status.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,SAAS,CAAC;AAChC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,cAAc,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,eAAe,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,wCAAwC;IACxC,UAAU,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,yCAAyC;IACzC,SAAS,EAAE,OAAO,CAAC;IACnB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,MAAM,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IAC3B,sCAAsC;IACtC,UAAU,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;CACnC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAC3B,IAAI,EAAE,EACN,aAAa,KAAK,CAAC,kBAAkB,CAAC,KACpC,OAAO,CAAC,QAAQ,CA8ElB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,QAAQ,KAAG,MA6BnD,CAAC"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/status.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,SAAS,CAAC;AAChC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,cAAc,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,aAAa,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,2EAA2E;IAC3E,aAAa,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,wEAAwE;IACxE,UAAU,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,yCAAyC;IACzC,SAAS,EAAE,OAAO,CAAC;IACnB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,MAAM,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IAC3B,sCAAsC;IACtC,UAAU,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;IACnC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC5B;AA8BD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAC3B,IAAI,EAAE,EACN,aAAa,KAAK,CAAC,kBAAkB,CAAC,KACpC,OAAO,CAAC,QAAQ,CAoFlB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,QAAQ,KAAG,MA4CnD,CAAC"}