@ultimat3/db 29.4.0 → 30.1.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.
Files changed (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. package/src/ungeneratable.ts +30 -0
package/CLAUDE.md CHANGED
@@ -29,11 +29,7 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
29
29
 
30
30
  ## Connections and transactions
31
31
 
32
- - **`pglite.ts` is a pool of exactly one**; `reserve()` (`pglite-turns.ts`) serialises `BEGIN`s. Three
33
- rules: the plain path takes a turn; a statement inside a LIVE transaction on THIS client
34
- (`liveTxConnection()`, never `currentTx() !== undefined`) skips the queue; a reservation runs direct
35
- only while its turn is held. `pglite-embedded.test.ts`, `pglite.test.ts`,
36
- `pglite-two-clients.test.ts`, `pglite-observer.test.ts`.
32
+ - **`pglite.ts` is a pool of exactly one**; `reserve()` (`pglite-turns.ts`) serialises `BEGIN`s. Full rule: `docs/history/db.md`.
37
33
  - **The third rule is both drivers'**: `client.ts`'s pinned handle also runs direct only while held.
38
34
  `release()` is idempotent on both; `DbConnection` and `Turn` are `Disposable`.
39
35
  - **A pin is held by `using`, never a hand-rolled `try/finally`** (`withTransaction`,
@@ -48,36 +44,32 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
48
44
  - **Four codes are `retryable`** (`DB_ERROR_RETRY`); terminal ones stay unclassified
49
45
  (`errors-retry.test.ts`). Core's `retry()` executor is NOT adopted.
50
46
  - **`BEGIN` re-derives its isolation level from the closed set** (`isolationMode`; else `X_SQL_UNSAFE`).
51
- - **An aborted transaction is reported** (`X_DB_TRANSACTION_ABORTED`; README "Transactions that end
52
- badly"): a failure carrying a SQLSTATE marks the root's `abort`, only a successful `ROLLBACK TO`
53
- clears it (a failed one sets it), and `COMMIT` is then refused unsent. `commit-tag.ts` reads the
54
- COMMIT tag in BOTH funnels. `COMMIT` rejecting with no SQLSTATE is `X_DB_COMMIT_UNKNOWN`: neither
55
- list runs. `transaction-errors.ts`; `transaction-options.ts` holds `DbTx`, options, `BEGIN` text.
47
+ - **An aborted transaction is reported** (`X_DB_TRANSACTION_ABORTED`): `COMMIT` is then refused unsent; rejecting with no SQLSTATE is `X_DB_COMMIT_UNKNOWN`. Full rule: `docs/history/db.md`.
48
+ - **A finished scope refuses** (`transaction-guard.ts`): `X_DB_TRANSACTION_CLOSED` once `outcome` left `open`; a coded `abandonment()` at a statement, nested scope, `BEGIN`, `COMMIT`, unless `completeTransaction()` marked the root. **Never in `runOn`.** README.
56
49
  - **Sibling nested scopes take turns** (`TxState.children`; savepoints are a stack) under a deadline
57
50
  (`sibling-turn.ts`, `siblingWaitMs`, `X_DB_SIBLING_SCOPE_TIMEOUT`). A nested
58
51
  `isolation`/`readOnly`/`deferrable`/foreign `client` is `X_INVARIANT`.
59
- - **`close()` is BOUNDED by the driver's own `{ timeout }` in SECONDS** (`drainTimeoutMs / 1000`; `0`
60
- sends none); the verdict is elapsed `performance.now()` (`X_DB_DRAIN_TIMEOUT`). It clears the
61
- cached driver before awaiting the teardown. `pool-drain{,.live}.test.ts`.
62
- - **`client.listen` is ONE session beside the pool** (`listen.ts`; `Bun.SQL.listen`, PGlite's
63
- `listen` under a turn), never a reserved pin. `onListening` fires on every re-dial; a channel is
64
- refused unless it is a plain identifier. `listen.test.ts`, `listen.live.test.ts`.
65
- - `execute()` trusts the command tag only when `> 0`, in both drivers (`rowsOf`, `affectedBy`).
66
- - **`pool-gauge.ts` derives `db_pool_max` / `db_pool_in_use` / `db_pool_waiting` from DEMAND**
67
- (`Bun.SQL` publishes no occupancy). `client.ts` is the one counter: `run()` from send to settle, a
68
- pin from ask to release; a statement ON a pin is not counted again. Declared on the first pool.
52
+ - **`close()` is BOUNDED by the driver's own `{ timeout }` in SECONDS** (`X_DB_DRAIN_TIMEOUT`). Full rule: `docs/history/db.md`.
53
+ - **`dbClientInstalled()` reads `installed`, a flag only `setDbClient` writes**, never `ambient`, which
54
+ the lazy default also fills: a unit test that sent one statement through `db()` must not flip
55
+ entity's `testDriver()` onto a pool nobody asked for.
56
+ - **`client.listen` is ONE session beside the pool** (`listen.ts`), never a reserved pin. Full rule: `docs/history/db.md`.
57
+ - **`pool-gauge.ts` derives `db_pool_*` from DEMAND** (`Bun.SQL` publishes no occupancy); `client.ts`
58
+ is the one counter. Full rule: `docs/history/db.md`.
69
59
  - **`client.ts` connects, holds the client and the ambient `db()`**, and opens no socket at import;
70
- `pool-profile.ts`, `connection-url.ts`, `bun-sql.ts`, `pool-reserve.ts`, `db-health.ts` (`checkDb`)
71
- and `statement-funnel.ts` (`sendOn`/`runOn`) hold the rest.
60
+ `pool-profile.ts`, `connection-url.ts`, `bun-sql.ts`, `pool-reserve.ts`, `db-health.ts` (`checkDb`),
61
+ `statement-funnel.ts` (`sendOn`/`runOn`), `pool-gate.ts`, `pool-sweep.ts`, `pool-retire.ts`,
62
+ `pool-listeners.ts` and `pool-timer.ts` hold the rest.
63
+ - **`pool-gate.ts` is the one count of `max`** (`takeSlot`/`freeSlot`), per pool; a wait past `acquireTimeoutMs` is `X_DB_POOL_EXHAUSTED`. Full rule: `docs/history/db.md`.
64
+ - **`pool-sweep.ts`: an unanswered statement retires the pool** (`statementTimeoutMs + UNANSWERED_GRACE_MS`), pooled or pinned; age is ticks that fired, never a clock. Full rule: `docs/history/db.md`.
65
+ - **`pool-pins.ts`: a pin is swept against ITS bound** (a `statement_timeout` it set; the role's after `COMMIT`/`ROLLBACK`); after a miss it sends nothing. `reserve()` passes `reserveWithin` what the gate left of one budget. **`pool-listeners.ts` holds `client.listen`** and re-dials every session on a retirement, with backoff.
66
+ - **`pooler.ts` owns the pooler**: `postgresClient({ env })` is the one reader; everything after asks `client.pooler`. Never a detection. `pooler.live.test.ts`. Full rule: `docs/history/db.md`.
67
+ - **`idleInTransactionMs` is sent beside `statement_timeout`**; a pooler refusing it is `X_DB_POOLER_OPTIONS_REFUSED`. Full rule: `docs/history/db.md`.
72
68
  - **`libpq-options.ts` merges the framework's `options` into the operator's**: the framework wins
73
69
  on the names it sets, the operator keeps the rest; emitted for all six roles.
74
70
  - **`DATABASE_URL`'s scheme is screened at boot** (`POSTGRES_SCHEMES`: `postgres:`, `postgresql:`); the
75
71
  received scheme is never echoed (it may be a credential).
76
- - **A JS array bound as a parameter is rendered here** (`array-parameter.ts`, `bound-parameters.ts`,
77
- called only by `sendOn`) — `Bun.SQL` joins elements with commas. `NULL` bare vs `"NULL"`; quoting by
78
- content; a `Uint8Array` is BYTEA, in an array too; a ragged nest and an Invalid Date are refused
79
- (`X_INVARIANT`), ABOVE the driver's `try` in both funnels. `array-parameter.live.test.ts`;
80
- `packages/cli/src/pg-array.live.test.ts` is the composition test.
72
+ - **A JS array bound as a parameter is rendered here** (`array-parameter.ts`, `bound-parameters.ts`, called only by `sendOn`) — `Bun.SQL` joins elements with commas. Full rule: `docs/history/db.md`.
81
73
  - **Every numeric option is screened** through core's `finiteCount` (`replicaClient`'s breaker,
82
74
  `migrate`'s `lockWaitMs`, `readonlyQuery`'s `timeoutMs` — only an explicit `0` disables it — the pool
83
75
  profile, `reapBranches`' `maxAgeMs`).
@@ -100,6 +92,10 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
100
92
  - **`expected-loop.ts` is the ONLY suppression**: `expectedQueryLoop(reason, fn)`, innermost reason,
101
93
  blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement.
102
94
  The framework's own loops declare themselves (`migrate()`, `rollback()`, admin's `search.ts`).
95
+ Sibling: `expectedSeqScan` (plan sweep's exemption).
96
+ - **`failure-store-pg.ts` writes `x_failures` through `outsideTransaction`** — the 5xx rolled the
97
+ request's transaction back. Table applied by the boot (`FRAMEWORK_SCHEMA`) whether capture is on
98
+ or not; bundles only (counts are `x_error_groups`'). `failurePurgeTarget` has no default window.
103
99
  - `@ultimat3/jobs` never imports this package; its statements pass the observer through
104
100
  `dbExecutor` (`db-executor.ts`, the ONE `PgExecutor` builder; the CLI binds it), unattributed.
105
101
 
@@ -110,52 +106,29 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
110
106
  `recordingClient` stubs the lock as `locked: true`.
111
107
  - **`lock_timeout` is the migration's** (`SET LOCAL` inside each migration's transaction, from the
112
108
  `migrate` profile's 3 s). `migration-step.ts` applies a script.
113
- - **`-- transaction: false` runs a migration outside a transaction** (`concurrent-index.ts`, a marker
114
- on `up` that governs `down`): a concurrent statement bare and unbounded, any other in its own tx; an
115
- INVALID index holding a concurrent build's name is dropped first. A concurrent statement in a
116
- transactional migration is `X_MIGRATION_CONCURRENT_IN_TRANSACTION`, before `BEGIN`.
109
+ - **`-- transaction: false` runs a migration outside a transaction** (`concurrent-index.ts`); a
110
+ concurrent statement in a transactional one is `X_MIGRATION_CONCURRENT_IN_TRANSACTION`. Full rule: `docs/history/db.md`.
111
+ - **`migrate-lock-retry.ts` sends again on `X_DB_LOCK_TIMEOUT`**. Full rule: `docs/history/db.md`.
112
+ - **`rolling.ts` decides WHAT a previous release trips over** (a closed list); `rolling-errors.ts` holds every `X_MIGRATION_ROLLING_UNSAFE`. Full rule: `docs/history/db.md`.
117
113
  - **The advisory lock is held by one pinned session, and `migrate()`/`rollback()` run every statement
118
114
  on it.** `lock: false` reserves nothing and takes no lock; no shipped path passes it
119
115
  (`migrate-pin.test.ts`). `migrate.live.test.ts` pins concurrent and failed-midway runs.
120
- - **One send is one statement**: `applyScript` sends `statementsOf(script)` one at a time.
121
- **`statement-split.ts` is the only splitter** (a left-to-right scan; `$1` is never a `$tag$`; `\`
122
- escapes only inside `E''`; comment-only chunks dropped). **`sql-scan.ts` is the one
123
- lexer** (`noiseAt`, source order; a `$tag$` needs separating from the identifier before it);
124
- `sql-noise.ts` holds `stripSqlNoise`.
116
+ - **One send is one statement**: `applyScript` sends `statementsOf(script)` one at a time. Full rule: `docs/history/db.md`.
125
117
  - **`destructive.ts` decides WHAT is destructive**: only `up`, a closed list of four (`drop table`,
126
118
  `drop column`, `truncate`, `alter column … type`), decided on blanked text and reported on the
127
119
  original; a marker is a top-level line comment (`marker-line.ts`). `X_MIGRATION_DESTRUCTIVE` (ship) and `X_MIGRATION_IRREVERSIBLE` (generate) are
128
120
  two questions.
129
121
  - **The ledger audit asks one question** — `auditLedger`'s `foreign` filter is `!known.has(row.id)`;
130
122
  the app version lives in the cause.
131
- - **`rollback({ steps })` refuses anything but a positive safe integer**, before the lock
132
- (`rollbackStepsInvalid`, `X_INVARIANT`).
133
- - **`refuseDependentViews(tx, script)`** (`dependent-view.ts`) runs before each migration's first
134
- statement: a word scan over `sql-scan.ts` finds retyped columns, one catalog round trip, the pair
135
- filtered in JS (visible tables only), and `X_MIGRATION_VIEW_DEPENDS` carries the `drop view` /
136
- `create view` from `pg_get_viewdef` (built through `identifier()` inside a `try`).
137
- - `runningAppVersion()` delegates to core's `appVersion()`.
123
+ - **`refuseDependentViews(tx, script)`** (`dependent-view.ts`) runs before each migration's first statement. Full rule: `docs/history/db.md`.
138
124
 
139
125
  ## Generation (`x db gen`)
140
126
 
141
127
  - **`generate.ts` reads an index, never re-derives one** — `IndexDescriptionLike` carries columns,
142
128
  unique, `where`, `order`, `using`; an index naming no column is `X_INVARIANT`.
143
- - **An index's ACCESS METHOD is carried end to end** (`index-method.ts`): closed at `btree` and `gin`;
144
- declared is CLOSED, live is OPEN (`indexMethodOf` passes the catalog through; `declaredMethod`
145
- refuses); absent is `btree` through one function; `snapshotOf` records `using` only when declared;
146
- `indexMethodSql` re-derives the literal (`X_SQL_UNSAFE` default); a unique or ordered GIN is
147
- `X_INVARIANT`. `introspectSchema()` reads `pg_am` (`introspect-embedded.test.ts`).
148
- - **`index-plan.ts` walks both directions** (declared first, removed last); four names are skipped
149
- (primary, moved aside, rebuilt, over a dropped column). **A removal and a `concurrently: true`
150
- build on a live table go to the non-transactional step** (`concurrent-plan.ts`; `generate-steps.ts`
151
- makes it `<id>_concurrently` or the whole migration); a shape a constraint could back
152
- (`mayBeConstraintBacked`) is unhooked in the step, after its builds. Never recorded (history).
153
- - **An entity's INVARIANTS reach the DDL** (`invariant-ddl.ts`): a `check` is a named constraint, a
154
- `unique` a unique INDEX on the one `declaredIndexes` list, an `assert` nothing; `checks` is recorded
155
- absent-never-`[]`; the name `<table>_<name>_<check|key>` is re-derived, bounded at 63 bytes and
156
- validated (`constraintNameUnsafe`). **An `assert` is an unrendered loss when a migration recorded its
157
- CHECK**: `unrenderedOf(entities, current)` takes the recorded schema (required, nullable) and
158
- `namesConstraint` matches both spellings.
129
+ - **An index's ACCESS METHOD is carried end to end**. Full rule: `docs/history/db.md`.
130
+ - **`index-plan.ts` walks both directions**; a removal and a `concurrently: true` build on a live table go to the non-transactional step (`concurrent-plan.ts`). Full rule: `docs/history/db.md`.
131
+ - **An entity's INVARIANTS reach the DDL** (`invariant-ddl.ts`): `check` a named constraint, `unique` a unique index, `assert` nothing. Full rule: `docs/history/db.md`.
159
132
  - **A COLUMN's CHECK is a named constraint too** (`check-ddl.ts`): one list (`declaredChecks` =
160
133
  `columnChecks` then `invariantChecks`), named `<table>_<column>_check` (Postgres' own name); an add on
161
134
  an existing column is `drop constraint if exists` then `add constraint`; two declarations naming one
@@ -166,28 +139,29 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
166
139
  - **`literal()` DOES receive caller input** (`column-default.ts`); `E'…'` only with a backslash, so
167
140
  every migration on disk stays byte-identical (`generate-default.live.test.ts`, `sql.test.ts`).
168
141
  `readonly-role.ts` and `branch.ts` are safe only by their ordering after `identifier()`.
169
- - **A retype moves its dependents aside** (`retype-dependents.ts`): only expressions that MENTION the
170
- column (partial-index predicates, CHECKs), over-approximated on purpose (`referencesColumn` walks
171
- `sql-scan.ts`); a plain btree survives. `generate-retype.live.test.ts`. What is moved is put back by
172
- the ordinary diff (`MovedAside`), never twice. **Foreign keys over a retyped column**
173
- (`retype-keys.ts`): the retype set is derived once for the whole schema (`retypedColumns`), drops go
174
- in `preAlters` at the top of `up`, re-adding is `foreignKeyPlan`'s, both `breaksOn` ends are needed
175
- (`generate-retype-key.live.test.ts`). `sql-type.ts` reads `SQL_TYPES` with `Object.hasOwn`.
142
+ - **A retype spans its default** (`retypeOverDefault`): `42804` otherwise.
143
+ - **A retype moves its dependents aside** (`retype-dependents.ts`, `retype-keys.ts`): expressions mentioning the column and foreign keys over it; the ordinary diff puts them back, never twice. Full rule: `docs/history/db.md`.
176
144
  - **A generated column** (`generated-column.ts`): plain → generated rebuilds (`rebuilt`); the rest of
177
145
  its rules are in `docs/history/db.md` (2026-10-31). `generate-generated-{column,rebuild}.live.test.ts`.
178
146
  - **`REPLICA IDENTITY FULL` is a PARAMETER** (`GenerateOptions.replicaIdentityFull`, from the CLI's
179
147
  `db-generate.ts`; `replica-identity.ts`): recorded `true` or absent, as a union; last in `up`; an
180
148
  undeclared name skipped; `down` reverts it unless this migration created the table.
181
- - **`appendOnly: true` is a trigger** (`generate-append-only.ts`): one `create or replace` function
182
- (SQLSTATE `23001`, message leads `X_ENTITY_APPEND_ONLY`) and a fixed-name `ultimate_append_only`
183
- `before update or delete … for each row`; recorded `true` or absent; no `truncate`. Drift:
184
- enabled `triggerNames` (`drift-append-only.ts`, `X_APPEND_ONLY_TRIGGER_MISSING`).
185
- - **A foreign key is `alter table … add constraint`**, collected into a bucket merged after every table
186
- statement (`foreign-key-plan.ts`); **dropping a table has its own bucket emitted BEFORE the table
187
- statements** (`preDrops`), ordered children-first by `drop-order.ts`, which breaks a two-table cycle
188
- by dropping one key first. `foreignKeyPlan` walks both directions, drops the name the previous
189
- snapshot recorded, and rebuilds a key whose `onDelete` moved. **`on delete` reaches the SQL**
190
- (`onDeleteRule`, `foreign-key.ts`; an unknown rule is `X_INVARIANT`).
149
+ - **`appendOnly: true` is a trigger** (`generate-append-only.ts`, `ultimate_append_only`); drift is
150
+ `X_APPEND_ONLY_TRIGGER_MISSING` (`drift-append-only.ts`). Full rule: `docs/history/db.md`.
151
+ - **A foreign key is `alter table … add constraint`** after every table statement (`foreign-key-plan.ts`); a table drop's bucket runs first, children-first (`drop-order.ts`). Full rule: `docs/history/db.md`.
152
+ - **`same-tenant.ts`**: `sameTenant` is `foreignKeysOf`'s composite; `withSameTenantIndexes` opens `generateMigration` and `snapshotOf`, idempotent. `set null (a)` is the rule's spelling everywhere (`introspectSchema()` off `pg_get_constraintdef`, never `confdelsetcols`). `foreign-key-plan.test.ts`. Why: `docs/history/db.md` (2026-11-02).
153
+ - **The deferred step holds keys too** (`concurrent-plan.ts`, phases in `stepStatements`: index builds, `keys`, `validate`, `contractKeys`, `contract`; `down` reversed: restore old key, drop new key, drop index). Full rule: `docs/history/db.md`.
154
+ - **Lifecycle triggers are one list per table** (`generate-triggers.ts`: `declaredTriggers`,
155
+ `triggerPlan`, `recordTriggers`): `history` → `ultimate_history` + `ultimate_history_guard`
156
+ (`generate-history.ts`), each `countedIn` → `ultimate_count_<crc32>` on the CHILD
157
+ (`generate-counter.ts`). Triggers, not the repository: only a trigger sees `updateWhere`, a cascade
158
+ and raw SQL. A history image stores `HistoryDescriptionLike.text` (`int8`/`numeric`) as digits and
159
+ `.instants` (`timestamptz`) as UTC ISO ms, as memory does (`generate-history-values.live.test.ts`).
160
+ Recorded on the snapshot; a NEW counter is recounted in the same migration. Drift:
161
+ `drift-triggers.ts` (`X_LIFECYCLE_TRIGGER_MISSING`; `retain` on a partitioned table is
162
+ `X_ENTITY_RETAIN_INVALID`).
163
+ - **Views**: `generate-view.ts`.
164
+ - **`retired.ts`: a retired column**. Full rule: `docs/history/db.md`.
191
165
  - **`entity-shape.ts` holds the `*Like` interfaces** (every later field optional).
192
166
  - **`snapshot-json.ts` writes a fixed point of Biome** (an array collapses when it fits at `<= 100`
193
167
  with its trailing comma); `snapshot-json.test.ts` runs `biome format`.
@@ -216,28 +190,29 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
216
190
  together (`unnest(a, b) with ordinality`), pinned by `introspect-embedded.test.ts`.
217
191
  - **`appTables()`** excludes all of `x_` for drift; `introspectSchema()` alone excludes
218
192
  `x_migrations` by default. **`app-relation.ts`**: `nonAppRelations(client, schema)` — extension
219
- ownership from `pg_depend` (`deptype = 'e'`) plus views, materialised views and foreign tables —
193
+ ownership (`pg_depend`, `deptype = 'e'`), views, materialised views, foreign tables, partitions —
220
194
  merged into `excluded` unconditionally.
221
- - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`) and is the only one —
222
- `@ultimat3/entity`'s copy is deleted. `errors.ts` registers `DB_ERROR_TITLES` unconditionally.
223
- - `drift-findings.ts` holds every `DriftDifference` constructor and `DriftKind`; `drift.ts` keeps the
224
- comparisons.
195
+ - **A partitioned parent is a table, a partition is not**; a concurrent build on one is
196
+ `X_MIGRATION_CONCURRENT_PARTITIONED`. `docs/history/db.md` (2026-11-06).
197
+ - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`), the only one.
198
+ `errors.ts` registers `DB_ERROR_TITLES` unconditionally.
199
+ - **`not-valid-constraint`** (`compareValidated`): `notValidNames` is catalog-only (`convalidated`, absent when none), judged on the declared side, foreign keys by where they point; the fix is the validate statement (`repair`).
200
+ - `drift-findings.ts` holds the `DriftDifference` constructors and `DriftKind`; `drift-missing.ts` the
201
+ missing/rebuilt ones (table, column, index, foreign key); `drift.ts` keeps the comparisons. **No
202
+ drift fix is `x db migrate`**: the ledger holds the migration, so it applies nothing — every fix is a
203
+ `repair()` from the snapshot or `byHand()`, run and re-checked by `drift-repair.test.ts`.
225
204
 
226
205
  ## The schema dump
227
206
 
228
207
  - **Its own entry: `@ultimat3/db/schema-dump`** (`schema-dump-entry.ts`). The barrel is in every
229
208
  role's boot graph and must evaluate none of this family — `schema-dump-entry.test.ts`.
230
- - **Two readings of one catalog, never mixed.** `introspectSchema()` → `SchemaDescription`, the entity
231
- vocabulary a snapshot is diffed in. `introspectCatalog()` (`catalog.ts`; queries: `catalog-relations.ts`,
232
- `catalog-objects.ts`; pure fold: `catalog-fold.ts`) → `CatalogDescription`, Postgres' own `pg_get_*def` text, compared only to
233
- itself. A catalog spelling on a `SchemaDescription` field is the `checks`/`checkNames` mistake.
209
+ - **Two readings of one catalog, never mixed.** `introspectSchema()` → `SchemaDescription`, the entity vocabulary a snapshot is diffed in. Full rule: `docs/history/db.md`.
234
210
  - **Sorted in JS** (`byCodeUnit`), never `order by` and never `localeCompare`: collations differ.
235
211
  - **`renderSchemaDump()` is pure**; `schema-dump-table.ts` spells one table. `quoted()` escapes any
236
212
  catalog name — `identifier()` refuses whitespace and `"`, which a migration may have created.
237
213
  - **`x_` owner → `framework/` twin** (`FRAMEWORK_TABLE_PREFIX`). No list is handed in.
238
- - **What cannot be rendered is named** (`unrenderedRows`, a closed list → `unrendered.sql`; a trigger
239
- on an unrendered relation is named by the fold). A kind rendered later leaves that list in the
240
- same diff. Never render a partition as a plain table. `generated` carries `stored`/`virtual`.
214
+ - **What cannot be rendered is named** (`unrenderedRows` → `unrendered.sql`); never render a
215
+ partition as a plain table (`partition-order.ts`). Full rule: `docs/history/db.md`.
241
216
  - **`loadSchemaDump()`**: one transaction, `check_function_bodies` off, a savepoint per file, and
242
217
  only `42P01`/`42883`/`42704` are retried. Its refusal is `X_SCHEMA_DUMP_DRIFT` (`dump-drift.ts`).
243
218
  - **`object-drift.ts`**: `unexpectedObjects(live, expected)` compares IDENTITY (kind, table, name,
@@ -245,34 +220,23 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
245
220
  - **No app path here.** Where the dump lives, which engine replays, and when it is written are
246
221
  `@ultimat3/cli`'s (`db-schema-dump.ts`). `schema-load.contract.test.ts` reads the reference app's
247
222
  migrations as text and runs on Postgres when `TEST_DATABASE_URL` is set.
248
- - **`pglite-extensions.ts` is the one linker** (`linkPgliteExtensions` → `{ linked, missing }`):
249
- `contrib/<name>` then the package root; the name is data (read from migration text), screened
250
- by `pgliteExtensionExport` before it reaches a specifier; `plpgsql` is built in, never missing.
251
- Only a not-found import is `missing`; a bundle that throws is `X_DB_UNAVAILABLE`.
252
- - **`pglite-snapshot.ts`**: `snapshotDir` makes a `memory://` boot a restore. Key = PGlite version
253
- (unreadable = no cache), never the extension set. One file, checksum in its header; unsound or
254
- unopenable → deleted and rebuilt; unreadable → a miss. Temp name + `rename`. Uncompressed.
255
- - **Embedded boots for dump tests**: `schema-dump.test.ts` and `schema-dump-fidelity.test.ts`.
223
+ - **`pglite-extensions.ts` is the one linker** (`linkPgliteExtensions`); a name read from migration
224
+ text is screened by `pgliteExtensionExport` before it reaches a specifier. Full rule: `docs/history/db.md`.
225
+ - **`pglite-snapshot.ts`**: `snapshotDir` makes a `memory://` boot a restore, keyed by PGlite version. Full rule: `docs/history/db.md`.
256
226
 
257
227
  ## Branches, replicas, read-only
258
228
 
259
229
  - **`reapBranches` sweeps branches of THIS database**: the marker is `ultimate:branch:<base>:<iso>`,
260
230
  split on the ISO tail; an older one-segment marker or an unparseable `createdAt` is skipped, never
261
231
  dropped. `@ultimat3/cli`'s `ls`/`drop` scope by name prefix.
262
- - **Read replicas are opt-in twice**: a pool when `DATABASE_REPLICA_URL` names one
263
- (`default-client.ts`), and a read offered only inside `withReplicaReads(fn)` (`replica-scope.ts`);
264
- read-your-writes is `ReplicaScope.wrote`, never a request-id map. **`withTransaction` is on the
265
- primary structurally** (`replicatedClient` delegates `reserve()`; `isReservable` answers about the
266
- database); `runRoot` calls `markScopeWrote()` unless `readOnly`. **`isPlainRead` is an allow-list**,
267
- never `statementKind()` (`replica-route.test.ts` asserts they disagree). A standby refusal (`25006`)
268
- re-runs on the primary; the breaker (3 failures, 10 s, `Clock.monotonic()`) parks the replica;
269
- `ReplicaStats`; `db.replica_fallback`. The URL must name a read-only standby — nothing checks it.
270
- `@ultimat3/cli`'s boot opens the per-request scope.
271
- - **This package owns no "is this SQL a write?" lexer** — `readonly.ts` and `X_READONLY_VIOLATION`
272
- are deleted; `errors.test.ts` pins `DB_OWNED_ERROR_CODES`. The layers are `ensureReadOnlyRole()`
273
- (layer 1, returns `null` on a missing permission) and `readOnlyQuery()` (layer 2: `BEGIN READ ONLY`
274
- + statement timeout; it THROWS), with `@ultimat3/mcp` as layers 3–4. **`readOnlyQuery` takes ONE
275
- statement** (`multipleStatements`, via `statementsOf`) and splices the splitter's `statements[0]`.
232
+ - **Read replicas are opt-in twice**: a pool when `DATABASE_REPLICA_URL` names one (`default-client.ts`), and a read only inside `withReplicaReads(fn)` (`replica-scope.ts`). `withTransaction` stays on the primary; `isPlainRead` is an allow-list; the breaker is core's (`db.replica`). Full rule: `docs/history/db.md`.
233
+ - **Read-your-writes is one comparison** (`replica-position.ts`): `pg_current_wal_insert_lsn()`
234
+ after the write, never `pg_current_wal_lsn()` (it trails under `synchronous_commit = off`), vs the
235
+ standby's `pg_last_wal_replay_lsn()`, sampled once per `POSITION_CACHE_MS`. Unknown = primary.
236
+ - **This package owns no "is this SQL a write?" lexer.** Layers: `ensureReadOnlyRole()`, `readOnlyQuery()` (one statement; it throws), then `@ultimat3/mcp`. Full rule: `docs/history/db.md`.
237
+ - **`db-diagnostics.ts` is `dbDiagnostics()`, on its own entry `@ultimat3/db/diagnostics`**: facts,
238
+ never a verdict (`checkDb` stays `/readyz`'s); a section fails alone; no statement reads a user
239
+ table. Full rule: `docs/history/db.md`.
276
240
 
277
241
  ```bash
278
242
  bun test # from packages/db