@fuzdev/fuz_app 0.41.0 → 0.42.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.
@@ -513,9 +513,96 @@ run'` if the seed somehow missed (defensive — migrations always seed).
513
513
  `permit_actor_role_active_unique` and installs scope-aware
514
514
  `permit_actor_role_scope_active_unique` using the
515
515
  `PERMIT_OFFER_SCOPE_SENTINEL_UUID`.
516
- - Forward-only (no down). Named migrations are preferred so the name
516
+ - Forward-only (no down). Migrations are `{name, up}` objects; the name
517
517
  surfaces in error messages.
518
518
 
519
+ #### Runner contract (`db/migrate.ts`)
520
+
521
+ The `schema_version` table stores **one row per applied migration**, keyed
522
+ by `(namespace, name)` with a monotonically-increasing per-namespace
523
+ `sequence` and `applied_at`. `run_migrations` reads applied rows ordered
524
+ by `sequence`, then enforces:
525
+
526
+ 1. **Length check first.** If `applied.length > code.length`, throw
527
+ `binary-older-than-db` listing the unknown names. Short-circuits
528
+ before name verify so a binary-older case with a rename in the overlap
529
+ doesn't fire `name-divergence-at-N` first and send the operator chasing
530
+ a phantom source-revert.
531
+ 2. **Name-prefix verify.** For each `i < applied.length`, assert
532
+ `applied[i].name === code[i].name`; mismatch throws
533
+ `name-divergence-at-N` with `at_index`.
534
+ 3. **Run the pending tail** (`code[applied.length..]`) inside a single
535
+ chain transaction; each `INSERT` uses `sequence = max(sequence) + 1`.
536
+
537
+ **Append-only after first publish.** Once a fuz_app version containing a
538
+ migration is published, the migration's name and position are frozen.
539
+ Pre-publish, anything goes; the cliff is the publish event. Body edits to
540
+ a published migration slip past the runner (no content hashing) — schema-
541
+ snapshot tests in consumers catch these.
542
+
543
+ `MigrationError` is the only error class thrown from `run_migrations` /
544
+ `baseline`; branch on `.kind` (never on message text). Kinds:
545
+ `binary-older-than-db`, `name-divergence-at-N`, `old-tracker-shape`,
546
+ `migration-failed`, `baseline-name-not-in-code`,
547
+ `baseline-name-out-of-order`, `baseline-namespace-already-populated`.
548
+
549
+ `baseline(db, ns, names)` is the only sanctioned non-execution path —
550
+ INSERTs tracker rows for a name-prefix of `ns.migrations` without running
551
+ their `up` functions. Used to promote an existing schema (e.g. preserved
552
+ through a tracker-shape upgrade) into the new tracker. Per-namespace
553
+ populated guard lets multi-call cutover scripts resume after partial
554
+ failure. `baseline()` does **not** verify the schema actually matches
555
+ what the named migrations would have produced — pair with a
556
+ schema-assertion script post-baseline.
557
+
558
+ There is **no programmatic bypass on the main `run_migrations` path**.
559
+ No `--force`, no `skip_verification`. If you need to deviate, reach for
560
+ `baseline()` (named, narrow) or direct SQL on the tracker (operator
561
+ explicitly states intent).
562
+
563
+ #### Operator recipes (run with the service stopped — these bypass the advisory lock)
564
+
565
+ **Rename a migration** (typo fix, etc.). This is a coordinated code+SQL
566
+ change, not just SQL:
567
+
568
+ 1. Stop the service. Disable auto-restart for the cutover window.
569
+ 2. Run the SQL `UPDATE` first — old code on disk doesn't read `name`, so
570
+ running this with the old build still deployed is harmless and the
571
+ safer order.
572
+ 3. Deploy the build with the renamed migration in the code array.
573
+ 4. Start the service — boot's name-prefix verify passes.
574
+
575
+ The bad order is "deploy code with new name, then SQL UPDATE" — boot
576
+ fires `name-divergence-at-N` and refuses to start in between.
577
+
578
+ ```sql
579
+ UPDATE schema_version SET name = 'new_name'
580
+ WHERE namespace = $ns AND name = 'old_name';
581
+ ```
582
+
583
+ **Mark a single migration applied without running it** (extreme repair —
584
+ prefer `baseline()` when promoting a whole prefix):
585
+
586
+ ```sql
587
+ INSERT INTO schema_version (namespace, name, sequence, applied_at)
588
+ VALUES ($ns, $name,
589
+ (SELECT COALESCE(MAX(sequence), -1) + 1
590
+ FROM schema_version WHERE namespace = $ns),
591
+ NOW());
592
+ ```
593
+
594
+ **Reset a namespace** (drop tracker rows; idempotent migrations re-apply
595
+ on next boot):
596
+
597
+ ```sql
598
+ DELETE FROM schema_version WHERE namespace = $ns;
599
+ ```
600
+
601
+ A `set_applied()` / `rename_applied()` helper was considered and
602
+ rejected — even one sanctioned bypass that doesn't name the operator's
603
+ intent invites use as a regular tool. Direct SQL forces the operator to
604
+ consciously violate the contract.
605
+
519
606
  ## Middleware
520
607
 
521
608
  Side of the chain ordering (concept-level — see the root `../../../CLAUDE.md`
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Auth schema migrations.
3
3
  *
4
- * Single v0 migration for the fuz identity system tables.
4
+ * Ordered list of `{name, up}` migrations for the fuz identity system tables.
5
5
  * Consumed by `run_migrations` with namespace `'fuz_auth'`.
6
6
  *
7
- * Collapsed to a single v0 for the 0.1.0 release — no production databases
8
- * exist, so the prior v0–v6 development iterations are consolidated.
9
- * Post-0.1.0, each new migration appends as v1, v2, etc.
7
+ * **Append-only after first publish.** Once a fuz_app version containing a
8
+ * given migration is published (`npm publish` / `jsr publish`), that
9
+ * migration's name and position are frozen. Never edit, rename, or reorder —
10
+ * append only. Pre-publish, anything goes; the cliff is the publish event.
11
+ * Body edits to a published migration slip past the runner (no content
12
+ * hashing) and are caught by schema-snapshot tests in consumers.
10
13
  *
11
14
  * To add a migration, append a new entry to `AUTH_MIGRATIONS`:
12
15
  *
13
16
  * ```ts
14
- * // v1: add display_name to account
17
+ * // v2: add display_name to account
15
18
  * {
16
19
  * name: 'account_display_name',
17
20
  * up: async (db) => {
@@ -21,8 +24,7 @@
21
24
  * ```
22
25
  *
23
26
  * Migrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`
24
- * for DDL safety. Named migrations (`{name, up}`) are preferred for
25
- * debuggability — the name appears in error messages on failure.
27
+ * for DDL safety. The `name` appears in error messages on failure.
26
28
  *
27
29
  * @module
28
30
  */
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA6BH,OAAO,KAAK,EAAC,SAAS,EAAE,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEpE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CA6D5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AA6BH,OAAO,KAAK,EAAC,SAAS,EAAE,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEpE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CA6D5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Auth schema migrations.
3
3
  *
4
- * Single v0 migration for the fuz identity system tables.
4
+ * Ordered list of `{name, up}` migrations for the fuz identity system tables.
5
5
  * Consumed by `run_migrations` with namespace `'fuz_auth'`.
6
6
  *
7
- * Collapsed to a single v0 for the 0.1.0 release — no production databases
8
- * exist, so the prior v0–v6 development iterations are consolidated.
9
- * Post-0.1.0, each new migration appends as v1, v2, etc.
7
+ * **Append-only after first publish.** Once a fuz_app version containing a
8
+ * given migration is published (`npm publish` / `jsr publish`), that
9
+ * migration's name and position are frozen. Never edit, rename, or reorder —
10
+ * append only. Pre-publish, anything goes; the cliff is the publish event.
11
+ * Body edits to a published migration slip past the runner (no content
12
+ * hashing) and are caught by schema-snapshot tests in consumers.
10
13
  *
11
14
  * To add a migration, append a new entry to `AUTH_MIGRATIONS`:
12
15
  *
13
16
  * ```ts
14
- * // v1: add display_name to account
17
+ * // v2: add display_name to account
15
18
  * {
16
19
  * name: 'account_display_name',
17
20
  * up: async (db) => {
@@ -21,8 +24,7 @@
21
24
  * ```
22
25
  *
23
26
  * Migrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`
24
- * for DDL safety. Named migrations (`{name, up}`) are preferred for
25
- * debuggability — the name appears in error messages on failure.
27
+ * for DDL safety. The `name` appears in error messages on failure.
26
28
  *
27
29
  * @module
28
30
  */
@@ -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"}
package/dist/db/status.js CHANGED
@@ -6,13 +6,29 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ const has_table_column = async (db, table_name, column_name) => {
10
+ const row = await db.query_one(`SELECT EXISTS (
11
+ SELECT 1 FROM information_schema.columns
12
+ WHERE table_schema = 'public'
13
+ AND table_name = $1
14
+ AND column_name = $2
15
+ ) as exists`, [table_name, column_name]);
16
+ return row?.exists ?? false;
17
+ };
18
+ const has_table = async (db, table_name) => {
19
+ const row = await db.query_one(`SELECT EXISTS (
20
+ SELECT 1 FROM information_schema.tables
21
+ WHERE table_schema = 'public' AND table_name = $1
22
+ ) as exists`, [table_name]);
23
+ return row?.exists ?? false;
24
+ };
9
25
  /**
10
- * Query database status including connectivity, tables, and migration versions.
26
+ * Query database status including connectivity, tables, and migration state.
11
27
  *
12
28
  * Designed for CLI `db:status` commands. Does not modify the database.
13
29
  *
14
30
  * @param db - the database instance
15
- * @param namespaces - migration namespaces to check versions for
31
+ * @param namespaces - migration namespaces to check status for
16
32
  * @returns a snapshot of database status
17
33
  */
18
34
  export const query_db_status = async (db, namespaces) => {
@@ -42,34 +58,42 @@ export const query_db_status = async (db, namespaces) => {
42
58
  row_count: result ? parseInt(result.count, 10) : 0,
43
59
  });
44
60
  }
45
- // check migration versions
61
+ // check migration state
46
62
  const migrations = [];
63
+ let old_tracker_shape;
47
64
  if (namespaces?.length) {
48
- // check if schema_version table exists
49
- const sv_exists = await db.query_one(`SELECT EXISTS (
50
- SELECT 1 FROM information_schema.tables
51
- WHERE table_schema = 'public' AND table_name = 'schema_version'
52
- ) as exists`);
53
- if (sv_exists?.exists) {
65
+ const sv_exists = await has_table(db, 'schema_version');
66
+ // pre-0.42 shape carries a `version` column; new shape carries `name`
67
+ const old_shape = sv_exists ? await has_table_column(db, 'schema_version', 'version') : false;
68
+ if (old_shape)
69
+ old_tracker_shape = true;
70
+ if (sv_exists && !old_shape) {
54
71
  for (const { namespace, migrations: ns_migrations } of namespaces) {
55
- const row = await db.query_one('SELECT version FROM schema_version WHERE namespace = $1', [namespace]);
56
- const current_version = row?.version ?? 0;
72
+ const rows = await db.query(`SELECT name FROM schema_version
73
+ WHERE namespace = $1
74
+ ORDER BY sequence ASC`, [namespace]);
75
+ const applied_names = rows.map((r) => r.name);
76
+ const code_names = ns_migrations.map((m) => m.name);
77
+ // pending = the suffix of code names beyond applied.length (callers
78
+ // see the boot algorithm's tail without paying for verify here)
79
+ const pending_names = code_names.slice(applied_names.length);
57
80
  migrations.push({
58
81
  namespace,
59
- current_version,
60
- available_version: ns_migrations.length,
61
- up_to_date: current_version === ns_migrations.length,
82
+ applied_names,
83
+ pending_names,
84
+ up_to_date: applied_names.length === code_names.length && pending_names.length === 0,
62
85
  });
63
86
  }
64
87
  }
65
88
  else {
66
- // no schema_version tableall namespaces are at version 0
89
+ // no tracker, or pre-0.42 shape every namespace shows as "nothing applied yet"
67
90
  for (const { namespace, migrations: ns_migrations } of namespaces) {
91
+ const code_names = ns_migrations.map((m) => m.name);
68
92
  migrations.push({
69
93
  namespace,
70
- current_version: 0,
71
- available_version: ns_migrations.length,
72
- up_to_date: ns_migrations.length === 0,
94
+ applied_names: [],
95
+ pending_names: code_names,
96
+ up_to_date: code_names.length === 0,
73
97
  });
74
98
  }
75
99
  }
@@ -79,6 +103,7 @@ export const query_db_status = async (db, namespaces) => {
79
103
  table_count: tables.length,
80
104
  tables,
81
105
  migrations,
106
+ ...(old_tracker_shape ? { old_tracker_shape: true } : {}),
82
107
  };
83
108
  };
84
109
  /**
@@ -102,12 +127,23 @@ export const format_db_status = (status) => {
102
127
  lines.push(` ${t.name.padEnd(max_name)} ${t.row_count} rows`);
103
128
  }
104
129
  }
130
+ if (status.old_tracker_shape) {
131
+ lines.push('');
132
+ lines.push(' Migrations: pre-0.42 schema_version shape detected.');
133
+ lines.push(' Drop the table and re-run, or call `baseline()` first if preserving the schema.');
134
+ }
105
135
  if (status.migrations.length > 0) {
106
136
  lines.push('');
107
137
  lines.push(' Migrations:');
108
138
  for (const m of status.migrations) {
109
- const marker = m.up_to_date ? 'up to date' : `${m.current_version}/${m.available_version}`;
110
- lines.push(` ${m.namespace}: v${m.current_version} (${marker})`);
139
+ const total = m.applied_names.length + m.pending_names.length;
140
+ if (m.up_to_date) {
141
+ lines.push(` ${m.namespace}: up to date (${m.applied_names.length}/${total})`);
142
+ }
143
+ else {
144
+ const pending_list = m.pending_names.join(', ');
145
+ lines.push(` ${m.namespace}: applied ${m.applied_names.length}/${total} (pending: ${pending_list})`);
146
+ }
111
147
  }
112
148
  }
113
149
  return lines.join('\n');
@@ -79,11 +79,12 @@ export declare const jsonrpc_errors: {
79
79
  /**
80
80
  * Maps JSON-RPC error codes to HTTP status codes.
81
81
  *
82
- * Extensible — consumers with domain-specific error codes can spread
83
- * this into their own mapping object. Frozen so the source can't be
84
- * accidentally mutated; spread copies are mutable.
82
+ * Extensible — consumers with domain-specific error codes assign directly
83
+ * (`JSONRPC_ERROR_CODE_TO_HTTP_STATUS[-32020] = 502`) at module load. The
84
+ * lookup function reads at call time, so mutation is the supported
85
+ * extension mechanism.
85
86
  */
86
- export declare const JSONRPC_ERROR_CODE_TO_HTTP_STATUS: Readonly<Record<number, number>>;
87
+ export declare const JSONRPC_ERROR_CODE_TO_HTTP_STATUS: Record<number, number>;
87
88
  /**
88
89
  * Maps HTTP status codes to JSON-RPC error codes (reverse mapping).
89
90
  *
@@ -91,7 +92,7 @@ export declare const JSONRPC_ERROR_CODE_TO_HTTP_STATUS: Readonly<Record<number,
91
92
  * and invalid_request both map to 400), the last one wins. Use for
92
93
  * best-effort HTTP → JSON-RPC translation.
93
94
  */
94
- export declare const HTTP_STATUS_TO_JSONRPC_ERROR_CODE: Readonly<Record<number, JsonrpcErrorCode>>;
95
+ export declare const HTTP_STATUS_TO_JSONRPC_ERROR_CODE: Record<number, JsonrpcErrorCode>;
95
96
  /**
96
97
  * Map a JSON-RPC error code to an HTTP status code.
97
98
  *
@@ -1 +1 @@
1
- {"version":3,"file":"jsonrpc_errors.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/jsonrpc_errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAMN,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,MAAM,cAAc,CAAC;AAEtB,0CAA0C;AAC1C,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAErD,sEAAsE;AACtE,MAAM,MAAM,gBAAgB,GACzB,aAAa,GACb,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,gBAAgB,GAChB,iBAAiB,GACjB,WAAW,GACX,WAAW,GACX,UAAU,GACV,kBAAkB,GAClB,cAAc,GACd,qBAAqB,GACrB,SAAS,GACT,gBAAgB,GAChB,mBAAmB,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EA0C1B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAmG7B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,kBAAkB,CAAC,CAAC,CAAC;AAEtF;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEH,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,YAAY;CAK3F;AAWD;;;;GAIG;AACH,eAAO,MAAM,cAAc;8CAXQ,kBAAkB;kDAAlB,kBAAkB;mDAAlB,kBAAkB;iDAAlB,kBAAkB;iDAAlB,kBAAkB;kDAAlB,kBAAkB;4CAAlB,kBAAkB;4CAAlB,kBAAkB;2CAAlB,kBAAkB;mDAAlB,kBAAkB;+CAAlB,kBAAkB;sDAAlB,kBAAkB;0CAAlB,kBAAkB;iDAAlB,kBAAkB;oDAAlB,kBAAkB;CA2BqC,CAAC;AAI3F;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAkB7E,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAQvF,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,iCAAiC,GAAI,MAAM,gBAAgB,KAAG,MAClB,CAAC;AAE1D;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,GAAI,QAAQ,MAAM,KAAG,gBACa,CAAC"}
1
+ {"version":3,"file":"jsonrpc_errors.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/jsonrpc_errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAMN,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,MAAM,cAAc,CAAC;AAEtB,0CAA0C;AAC1C,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAErD,sEAAsE;AACtE,MAAM,MAAM,gBAAgB,GACzB,aAAa,GACb,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,gBAAgB,GAChB,iBAAiB,GACjB,WAAW,GACX,WAAW,GACX,UAAU,GACV,kBAAkB,GAClB,cAAc,GACd,qBAAqB,GACrB,SAAS,GACT,gBAAgB,GAChB,mBAAmB,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EA0C1B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAmG7B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,kBAAkB,CAAC,CAAC,CAAC;AAEtF;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEH,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,YAAY;CAK3F;AAWD;;;;GAIG;AACH,eAAO,MAAM,cAAc;8CAXQ,kBAAkB;kDAAlB,kBAAkB;mDAAlB,kBAAkB;iDAAlB,kBAAkB;iDAAlB,kBAAkB;kDAAlB,kBAAkB;4CAAlB,kBAAkB;4CAAlB,kBAAkB;2CAAlB,kBAAkB;mDAAlB,kBAAkB;+CAAlB,kBAAkB;sDAAlB,kBAAkB;0CAAlB,kBAAkB;iDAAlB,kBAAkB;oDAAlB,kBAAkB;CA2BqC,CAAC;AAI3F;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAkBpE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAMzC,CAAC;AAEvC;;;;;;;;GAQG;AACH,eAAO,MAAM,iCAAiC,GAAI,MAAM,gBAAgB,KAAG,MAClB,CAAC;AAE1D;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,GAAI,QAAQ,MAAM,KAAG,gBACa,CAAC"}
@@ -203,11 +203,12 @@ export const jsonrpc_errors = {
203
203
  /**
204
204
  * Maps JSON-RPC error codes to HTTP status codes.
205
205
  *
206
- * Extensible — consumers with domain-specific error codes can spread
207
- * this into their own mapping object. Frozen so the source can't be
208
- * accidentally mutated; spread copies are mutable.
206
+ * Extensible — consumers with domain-specific error codes assign directly
207
+ * (`JSONRPC_ERROR_CODE_TO_HTTP_STATUS[-32020] = 502`) at module load. The
208
+ * lookup function reads at call time, so mutation is the supported
209
+ * extension mechanism.
209
210
  */
210
- export const JSONRPC_ERROR_CODE_TO_HTTP_STATUS = Object.freeze({
211
+ export const JSONRPC_ERROR_CODE_TO_HTTP_STATUS = {
211
212
  [-32700]: 400, // parse_error
212
213
  [-32600]: 400, // invalid_request
213
214
  [-32601]: 404, // method_not_found
@@ -225,7 +226,7 @@ export const JSONRPC_ERROR_CODE_TO_HTTP_STATUS = Object.freeze({
225
226
  [-32007]: 503, // service_unavailable
226
227
  [-32008]: 504, // timeout
227
228
  [-32010]: 499, // request_cancelled (nginx "client closed request")
228
- });
229
+ };
229
230
  /**
230
231
  * Maps HTTP status codes to JSON-RPC error codes (reverse mapping).
231
232
  *
@@ -233,10 +234,10 @@ export const JSONRPC_ERROR_CODE_TO_HTTP_STATUS = Object.freeze({
233
234
  * and invalid_request both map to 400), the last one wins. Use for
234
235
  * best-effort HTTP → JSON-RPC translation.
235
236
  */
236
- export const HTTP_STATUS_TO_JSONRPC_ERROR_CODE = Object.freeze(Object.fromEntries(Object.entries(JSONRPC_ERROR_CODE_TO_HTTP_STATUS).map(([code, status]) => [
237
+ export const HTTP_STATUS_TO_JSONRPC_ERROR_CODE = Object.fromEntries(Object.entries(JSONRPC_ERROR_CODE_TO_HTTP_STATUS).map(([code, status]) => [
237
238
  status,
238
239
  Number(code),
239
- ])));
240
+ ]));
240
241
  /**
241
242
  * Map a JSON-RPC error code to an HTTP status code.
242
243
  *
@@ -70,8 +70,9 @@ export interface CreateAppBackendOptions {
70
70
  audit_log_config?: AuditLogConfig;
71
71
  /**
72
72
  * Additional migration namespaces to run after the builtin auth namespace.
73
- * Each namespace's own `schema_version` row tracks progress; order is
74
- * append-only so forward-only guarantees hold per-namespace.
73
+ * The shared `schema_version` table records one row per applied migration
74
+ * (`namespace`, `name`, `sequence`); order is append-only so forward-only
75
+ * guarantees hold per-namespace.
75
76
  *
76
77
  * The reserved `'fuz_auth'` namespace is rejected at startup. Omit for no
77
78
  * extra namespaces. This is the only place to splice consumer migrations
@@ -1 +1 @@
1
- {"version":3,"file":"app_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAE/C,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAC,cAAc,EAAE,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC/E,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAiB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAI/F;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,QAAQ,CAAC,iBAAiB,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC3D,iEAAiE;IACjE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,+DAA+D;IAC/D,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACnD,2BAA2B;IAC3B,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChD;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,cAAc,CAAC;IAClC;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAAU,SAAS,uBAAuB,KAAG,OAAO,CAAC,UAAU,CAoC7F,CAAC"}
1
+ {"version":3,"file":"app_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAE/C,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAC,cAAc,EAAE,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC/E,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAiB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAI/F;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,QAAQ,CAAC,iBAAiB,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC3D,iEAAiE;IACjE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,+DAA+D;IAC/D,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACnD,2BAA2B;IAC3B,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChD;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,cAAc,CAAC;IAClC;;;;;;;;;OASG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAAU,SAAS,uBAAuB,KAAG,OAAO,CAAC,UAAU,CAoC7F,CAAC"}
@@ -81,19 +81,19 @@ Factory builders for parameterized DB tests. Consumer projects pass their
81
81
  `init_schema` callback (which calls `run_migrations(db, [AUTH_MIGRATION_NS, ...app_migrations])`);
82
82
  factories accept any migration namespace set.
83
83
 
84
- | Helper | Role |
85
- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | `IS_CI` | `process.env.CI === 'true'` — CI detection. |
87
- | `DbFactory` interface | `{name, create, close, skip, skip_reason?}`. |
88
- | `reset_pglite(db)` | `DROP SCHEMA public CASCADE` + recreate. Reuses a live PGlite instance. |
89
- | `create_pglite_factory(init_schema)` | In-memory; no external deps; `skip: false`. See WASM caching below. |
90
- | `create_pg_factory(init_schema, test_url?)` | PostgreSQL; `skip: true` when `test_url` is missing; drops `schema_version` before `init_schema` so migrations re-evaluate against actual tables; pool is reused + cleaned up across `create()` calls. |
91
- | `AUTH_TRUNCATE_TABLES` | `['invite', 'api_token', 'auth_session', 'permit', 'permit_offer', 'actor', 'account']` in FK-safe order. Excludes `audit_log` — unit DB tests don't need to truncate it. |
92
- | `AUTH_INTEGRATION_TRUNCATE_TABLES` | `AUTH_TRUNCATE_TABLES + ['audit_log']` — for integration suites that exercise the audit path. |
93
- | `AUTH_DROP_TABLES` | Full set from `AUTH_MIGRATIONS` in drop order; call `drop_auth_schema(db)` at the top of `init_schema` on persistent pg databases that may hold stale DDL from previous fuz_app versions. |
94
- | `drop_auth_schema(db)` | `DROP TABLE IF EXISTS <table> CASCADE` for every entry in `AUTH_DROP_TABLES` plus `schema_version`. Safe on fresh DBs. |
95
- | `create_describe_db(factories, truncate_tables)` | Returns `describe_db(name, fn)` that runs `fn(get_db)` once per factory, inside a `describe` block with shared `beforeAll(create)` + `beforeEach(TRUNCATE)` + `afterAll(close)`. Skipped factories use `describe.skip`. |
96
- | `log_db_factory_status(factories)` | Console summary of enabled / skipped factories. |
84
+ | Helper | Role |
85
+ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `IS_CI` | `process.env.CI === 'true'` — CI detection. |
87
+ | `DbFactory` interface | `{name, create, close, skip, skip_reason?}`. |
88
+ | `reset_pglite(db)` | `DROP SCHEMA public CASCADE` + recreate. Reuses a live PGlite instance. |
89
+ | `create_pglite_factory(init_schema)` | In-memory; no external deps; `skip: false`. See WASM caching below. |
90
+ | `create_pg_factory(init_schema, test_url?)` | PostgreSQL; `skip: true` when `test_url` is missing; drops `schema_version` before `init_schema` so migrations re-evaluate against actual tables (prevents stale tracker rows from skipping migrations when DDL changes between test sessions); pool is reused + cleaned up across `create()` calls. |
91
+ | `AUTH_TRUNCATE_TABLES` | `['invite', 'api_token', 'auth_session', 'permit', 'permit_offer', 'actor', 'account']` in FK-safe order. Excludes `audit_log` — unit DB tests don't need to truncate it. |
92
+ | `AUTH_INTEGRATION_TRUNCATE_TABLES` | `AUTH_TRUNCATE_TABLES + ['audit_log']` — for integration suites that exercise the audit path. |
93
+ | `AUTH_DROP_TABLES` | Full set from `AUTH_MIGRATIONS` in drop order; call `drop_auth_schema(db)` at the top of `init_schema` on persistent pg databases that may hold stale DDL from previous fuz_app versions. |
94
+ | `drop_auth_schema(db)` | `DROP TABLE IF EXISTS <table> CASCADE` for every entry in `AUTH_DROP_TABLES` plus `schema_version`. Safe on fresh DBs. |
95
+ | `create_describe_db(factories, truncate_tables)` | Returns `describe_db(name, fn)` that runs `fn(get_db)` once per factory, inside a `describe` block with shared `beforeAll(create)` + `beforeEach(TRUNCATE)` + `afterAll(close)`. Skipped factories use `describe.skip`. |
96
+ | `log_db_factory_status(factories)` | Console summary of enabled / skipped factories. |
97
97
 
98
98
  **PGlite WASM caching.** `create_pglite_factory` shares a single PGlite
99
99
  instance in a module-level ref (`module_db`) across all factories in the
@@ -41,7 +41,7 @@ export declare const create_pglite_factory: (init_schema: (db: Db) => Promise<vo
41
41
  *
42
42
  * Skipped when `test_url` is not provided.
43
43
  * Drops `schema_version` before running `init_schema`, forcing migrations
44
- * to re-evaluate against the actual tables. Prevents stale version entries
44
+ * to re-evaluate against the actual tables. Prevents stale tracker rows
45
45
  * from skipping migrations when DDL changes between test sessions.
46
46
  *
47
47
  * For full clean-slate behavior (recommended), call `drop_auth_schema(db)`
@@ -82,7 +82,7 @@ export const create_pglite_factory = (init_schema) => ({
82
82
  *
83
83
  * Skipped when `test_url` is not provided.
84
84
  * Drops `schema_version` before running `init_schema`, forcing migrations
85
- * to re-evaluate against the actual tables. Prevents stale version entries
85
+ * to re-evaluate against the actual tables. Prevents stale tracker rows
86
86
  * from skipping migrations when DDL changes between test sessions.
87
87
  *
88
88
  * For full clean-slate behavior (recommended), call `drop_auth_schema(db)`
@@ -122,7 +122,7 @@ export const create_pg_factory = (init_schema, test_url) => {
122
122
  const { db } = create_pg_db(pool);
123
123
  try {
124
124
  // Drop schema_version so migrations re-evaluate against the actual
125
- // tables. Prevents stale version entries from skipping migrations
125
+ // tables. Prevents stale tracker rows from skipping migrations
126
126
  // when DDL changes between test sessions. Migrations use
127
127
  // IF NOT EXISTS guards, so re-running is safe.
128
128
  await db.query('DROP TABLE IF EXISTS schema_version');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",