@fuzdev/fuz_app 0.41.1 → 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.
- package/dist/auth/CLAUDE.md +88 -1
- 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/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/db.d.ts +1 -1
- package/dist/testing/db.js +2 -2
- package/package.json +1 -1
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -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).
|
|
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
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
* //
|
|
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.
|
|
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
|
|
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"}
|
package/dist/auth/migrations.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auth schema migrations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
* //
|
|
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.
|
|
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
|
*/
|
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"}
|
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
|
|
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
|
|
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
|
|
61
|
+
// check migration state
|
|
46
62
|
const migrations = [];
|
|
63
|
+
let old_tracker_shape;
|
|
47
64
|
if (namespaces?.length) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (sv_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
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
up_to_date:
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
up_to_date:
|
|
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
|
|
110
|
-
|
|
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');
|
|
@@ -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
|
-
*
|
|
74
|
-
* append-only so forward-only
|
|
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
|
|
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"}
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -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
|
package/dist/testing/db.d.ts
CHANGED
|
@@ -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
|
|
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)`
|
package/dist/testing/db.js
CHANGED
|
@@ -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
|
|
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
|
|
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');
|