@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.
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +14 -7
- package/dist/auth/CLAUDE.md +96 -9
- package/dist/auth/account_action_specs.d.ts +8 -8
- package/dist/auth/account_action_specs.js +4 -4
- package/dist/auth/admin_action_specs.d.ts +8 -8
- package/dist/auth/admin_action_specs.js +4 -4
- package/dist/auth/migrations.d.ts +9 -7
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +9 -7
- package/dist/db/migrate.d.ts +124 -39
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +244 -75
- package/dist/db/status.d.ts +13 -7
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +56 -20
- package/dist/http/schema_helpers.d.ts +9 -0
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +9 -0
- package/dist/server/app_backend.d.ts +3 -2
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/testing/CLAUDE.md +13 -13
- package/dist/testing/admin_integration.js +9 -9
- package/dist/testing/audit_completeness.js +3 -3
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.js +2 -2
- package/dist/testing/integration.js +36 -36
- package/dist/testing/rpc_helpers.d.ts +14 -6
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +8 -5
- package/dist/ui/admin_rpc_adapters.js +4 -4
- package/package.json +1 -1
package/dist/db/migrate.d.ts
CHANGED
|
@@ -1,45 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Identity-tracked database migration runner.
|
|
3
3
|
*
|
|
4
|
-
* Migrations are
|
|
5
|
-
* A `schema_version` table
|
|
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
|
-
* **
|
|
8
|
-
*
|
|
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
|
-
* **
|
|
13
|
-
*
|
|
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
|
-
* **
|
|
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
|
|
19
|
-
*
|
|
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
|
|
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
|
-
*
|
|
47
|
+
* Throw from `up` to roll back the entire chain.
|
|
34
48
|
*/
|
|
35
|
-
export
|
|
49
|
+
export interface Migration {
|
|
36
50
|
name: string;
|
|
37
|
-
up:
|
|
38
|
-
}
|
|
51
|
+
up: (db: Db) => Promise<void>;
|
|
52
|
+
}
|
|
39
53
|
/**
|
|
40
54
|
* A named group of ordered migrations.
|
|
41
55
|
*
|
|
42
|
-
* Array index =
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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**:
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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**:
|
|
70
|
-
*
|
|
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
|
|
74
|
-
* @returns
|
|
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
|
package/dist/db/migrate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/migrate.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
package/dist/db/migrate.js
CHANGED
|
@@ -1,38 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Identity-tracked database migration runner.
|
|
3
3
|
*
|
|
4
|
-
* Migrations are
|
|
5
|
-
* A `schema_version` table
|
|
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
|
-
* **
|
|
8
|
-
*
|
|
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
|
-
* **
|
|
13
|
-
*
|
|
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
|
-
* **
|
|
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
|
|
19
|
-
*
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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**:
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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**:
|
|
63
|
-
*
|
|
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
|
|
67
|
-
* @returns
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
91
|
-
|
|
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 =
|
|
94
|
-
const
|
|
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
|
|
98
|
-
await tx.query(`INSERT INTO schema_version (namespace,
|
|
99
|
-
VALUES ($1, $2,
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/db/status.d.ts
CHANGED
|
@@ -13,11 +13,11 @@ import type { MigrationNamespace } from './migrate.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export interface MigrationStatus {
|
|
15
15
|
namespace: string;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
/** Whether the
|
|
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
|
|
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
|
|
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>;
|
package/dist/db/status.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|