@ultimat3/db 29.4.0 → 30.0.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
@@ -49,6 +49,20 @@ export const siblingScopeTimeout = (
49
49
  meta: { parent, waitedMs, ...(holder === undefined ? {} : { holder }) },
50
50
  });
51
51
 
52
+ /**
53
+ * A statement on a handle whose scope is over: an un-awaited promise or the loser of a `Promise.all`
54
+ * outlived `withTransaction`. It used to fall through the pin's `held` fence to the pool and
55
+ * autocommit, a row for a unit of work that had rolled back. Terminal: the same call sent again
56
+ * reaches the same dead handle.
57
+ */
58
+ export const txClosed = (id: string, outcome: string): DbError =>
59
+ new DbError({
60
+ code: 'X_DB_TRANSACTION_CLOSED',
61
+ cause: `a statement was sent on transaction ${id} after its scope ended (${outcome}): an un-awaited promise, or the loser of a Promise.all, outlived withTransaction, and it was never part of that unit of work`,
62
+ fix: 'await outsideTransaction(fn) # for work that must outlive the scope; anything else: await it inside withTransaction(fn)',
63
+ meta: { tx: id, outcome },
64
+ });
65
+
52
66
  /**
53
67
  * `COMMIT` was sent and rejected with no SQLSTATE — the socket went before the answer did. The
54
68
  * transaction is durable or it is not, and nothing on this side can say which, so neither list
@@ -0,0 +1,40 @@
1
+ // Single responsibility: the two refusals a statement meets before it reaches a transaction's
2
+ // connection, a scope that is over and work whose caller was already answered, and the scope store
3
+ // the second reads. Never on `runOn`: `settleFailure` and a job step's fenced write send past the
4
+ // abort through that funnel on purpose.
5
+
6
+ import { abandonment, asyncContext } from '@ultimat3/core';
7
+ // Type-only: `transaction.ts` imports this module, and a runtime edge would close the cycle.
8
+ import type { TxState } from './transaction';
9
+ import { txClosed } from './transaction-errors';
10
+
11
+ /** `completeTransaction()`'s mark on a root. */
12
+ export type TxMark = { readonly value: boolean };
13
+
14
+ // Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
15
+ // browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of
16
+ // `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays
17
+ // nothing for the deferral.
18
+ export const storage = asyncContext<TxState>('a database transaction');
19
+
20
+ /** A `DbTx` statement after its scope ended, committed and rolled back alike: one rule. */
21
+ export function refuseIfFinished(id: string, outcome: string): void {
22
+ if (outcome !== 'open') throw txClosed(id, outcome);
23
+ }
24
+
25
+ /** The mark of the root still OPEN on this async context; a finished one exempts nothing. */
26
+ function openRootMark(): TxMark | undefined {
27
+ const state = storage.get();
28
+ return state?.live.value === true ? state.completing : undefined;
29
+ }
30
+
31
+ /**
32
+ * Throws the signal's own reason, the same object: no wrapper and no new code. A marked root is
33
+ * owed its COMMIT, so it passes. `completing` is the scope's own for a `DbTx` statement; omitted, a
34
+ * write with no handle (entity's `writing()`, the enqueue facade) answers to the ambient root.
35
+ */
36
+ export function refuseAbandoned(completing: TxMark | undefined = openRootMark()): void {
37
+ if (completing?.value === true) return;
38
+ const reason = abandonment();
39
+ if (reason !== undefined) throw reason;
40
+ }
@@ -0,0 +1,24 @@
1
+ // Single responsibility: the settings every root transaction begins with — the seam row security
2
+ // rides. `@ultimat3/entity` registers the provider (it alone knows the actor's tenant and whether a
3
+ // `crossTenant()` scope is open); `withTransaction` asks it once per BEGIN. Same shape as core's
4
+ // `currentWriteOrigin`: a value read at the moment of use, never threaded through a parameter.
5
+
6
+ import type { LocalSettings } from './local-settings';
7
+
8
+ type SettingsProvider = () => LocalSettings | undefined;
9
+
10
+ let provider: SettingsProvider | undefined;
11
+
12
+ /**
13
+ * Install the provider, or `undefined` to remove it. One provider, not a list: two writers of the
14
+ * same setting is a disagreement nobody could see. Unset — every app without row security — costs
15
+ * each BEGIN one comparison and no statement.
16
+ */
17
+ export function setTransactionSettings(next: SettingsProvider | undefined): void {
18
+ provider = next;
19
+ }
20
+
21
+ /** What the transaction opening now must set first, or `undefined` for nothing. */
22
+ export function transactionSettings(): LocalSettings | undefined {
23
+ return provider?.();
24
+ }
@@ -3,9 +3,10 @@
3
3
  // the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps
4
4
  // to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
5
5
 
6
- import { assert, asyncContext, finiteCount, nanoid } from '@ultimat3/core';
6
+ import { assert, finiteCount, nanoid } from '@ultimat3/core';
7
7
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
8
8
  import { DbError, serializationExhausted } from './errors';
9
+ import { setLocal } from './local-settings';
9
10
  import { createTurnQueue, type TurnQueue } from './pglite-turns';
10
11
  import { markScopeWrote } from './replica-scope';
11
12
  import { SIBLING_SCOPE_WAIT_MS, siblingTurn } from './sibling-turn';
@@ -13,9 +14,11 @@ import { raw, type SqlFragment } from './sql';
13
14
  import { isRetryableState, sqlState } from './sqlstate';
14
15
  import { serializationRetryDelayMs } from './transaction-backoff';
15
16
  import { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
17
+ import { refuseAbandoned, refuseIfFinished, storage, type TxMark } from './transaction-guard';
16
18
  import { beginStatement, type DbTx, type TransactionOptions } from './transaction-options';
19
+ import { transactionSettings } from './transaction-settings';
17
20
 
18
- interface TxState {
21
+ export interface TxState {
19
22
  readonly tx: DbTx;
20
23
  readonly connection: DbClient;
21
24
  readonly undos: (() => void)[];
@@ -37,6 +40,12 @@ interface TxState {
37
40
  readonly live: { value: boolean };
38
41
  /** Whether the SERVER has aborted the transaction — shared by reference, like `live`. */
39
42
  readonly abort: TxAbort;
43
+ /**
44
+ * The root must reach its COMMIT even if this scope is abandoned: `completeTransaction()` set it,
45
+ * so statements and COMMIT skip the abandonment check. Shared by reference across nesting like
46
+ * `live`, and new per attempt: a retry starts unmarked.
47
+ */
48
+ readonly completing: { value: boolean };
40
49
  /**
41
50
  * One CHILD scope at a time, per scope. Savepoints are a stack on the server: two siblings opened
42
51
  * under `Promise.all` interleaved `SAVEPOINT x_sp_1, SAVEPOINT x_sp_2`, and `RELEASE x_sp_1`
@@ -68,12 +77,6 @@ function noteFailure(abort: TxAbort, error: unknown): void {
68
77
  abort.first = error;
69
78
  }
70
79
 
71
- // Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
72
- // browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of
73
- // `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays
74
- // nothing for the deferral.
75
- const storage = asyncContext<TxState>('a database transaction');
76
-
77
80
  /** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */
78
81
  export function currentTx(): DbTx | undefined {
79
82
  return storage.get()?.tx;
@@ -96,8 +99,8 @@ export function outsideTransaction<R>(fn: () => R): R {
96
99
  * store survives the scope. The one reader is `pglite.ts`'s `run()`, where the answer decides
97
100
  * whether a statement may skip the single session's turn queue; skipping it on a *closed*
98
101
  * transaction is how a straggler landed inside whichever unit of work held the connection next.
99
- * `currentTx()` deliberately still answers with the dead handle: its statements go through the
100
- * reservation, whose own `held` fence already re-queues them.
102
+ * `currentTx()` deliberately still answers with the dead handle: its statements refuse
103
+ * (`X_DB_TRANSACTION_CLOSED`, `makeTx`), so nothing a straggler sends reaches any connection.
101
104
  *
102
105
  * The CONNECTION, never a boolean: "is any transaction open" let an autocommit statement on
103
106
  * client A, issued inside a transaction on client B, skip A's queue and run inside A's own open
@@ -113,7 +116,7 @@ export function liveTxConnection(): DbClient | undefined {
113
116
  * — a promise chain `fn` forgot to await, still inside the store after the scope closed — runs at
114
117
  * once after a COMMIT and is dropped after a ROLLBACK, rather than waiting on a list nobody reads.
115
118
  */
116
- type TxOutcome = { value: 'open' | 'committed' | 'rolled-back' | 'unknown' };
119
+ type TxOutcome = { value: 'open' | 'ending' | 'committed' | 'rolled-back' | 'unknown' };
117
120
 
118
121
  /** One statement on the scope's connection, its refusal remembered before the caller can drop it. */
119
122
  async function watched<T>(abort: TxAbort, sent: Promise<T>): Promise<T> {
@@ -133,19 +136,29 @@ function makeTx(
133
136
  origin: DbClient,
134
137
  outcome: TxOutcome,
135
138
  abort: TxAbort,
139
+ completing: TxMark,
136
140
  ): DbTx {
141
+ // `async` so a refusal rejects like the statement would have, and it sends synchronously, in
142
+ // call order. Never on `outer.connection` or the `ROLLBACK`s: those are how the scope ends.
143
+ const guarded = async <T>(send: () => Promise<T>): Promise<T> => {
144
+ refuseIfFinished(id, outcome.value);
145
+ refuseAbandoned(completing);
146
+ return watched(abort, send());
147
+ };
137
148
  return {
138
149
  id,
139
150
  origin,
140
- query: <T>(fragment: SqlFragment) => watched(abort, connection.query<T>(fragment)),
141
- one: <T>(fragment: SqlFragment) => watched(abort, connection.one<T>(fragment)),
142
- execute: (fragment: SqlFragment) => watched(abort, connection.execute(fragment)),
151
+ query: <T>(fragment: SqlFragment) => guarded(() => connection.query<T>(fragment)),
152
+ one: <T>(fragment: SqlFragment) => guarded(() => connection.one<T>(fragment)),
153
+ execute: (fragment: SqlFragment) => guarded(() => connection.execute(fragment)),
143
154
  onRollback: (undo: () => void) => {
144
155
  undos.push(undo);
145
156
  },
146
157
  onCommit: (effect: () => void) => {
147
- if (outcome.value === 'committed') runCommits([effect]);
148
- else if (outcome.value === 'open') commits.push(effect);
158
+ // Off the straggler's dead store: the effect is after the COMMIT, so `db()` in it is the pool.
159
+ if (outcome.value === 'committed') storage.exit(() => runCommits([effect]));
160
+ // 'ending' too: an in-flight COMMIT runs this list once answered, a ROLLBACK never does.
161
+ else if (outcome.value === 'open' || outcome.value === 'ending') commits.push(effect);
149
162
  },
150
163
  };
151
164
  }
@@ -180,12 +193,16 @@ async function runNested<T>(
180
193
  fn: (tx: DbTx) => Promise<T>,
181
194
  waitMs: number,
182
195
  ): Promise<T> {
196
+ refuseIfFinished(outer.tx.id, outer.outcome.value);
197
+ refuseAbandoned(outer.completing);
183
198
  // Held to the end of this function, on every exit: the next sibling's SAVEPOINT is sent only
184
199
  // after this scope's RELEASE or ROLLBACK TO has been answered. Under a deadline, because a body
185
200
  // awaiting a sibling queued behind it is a cycle (`sibling-turn.ts`).
186
201
  using _turn = await siblingTurn(outer.children, waitMs, () =>
187
202
  siblingScopeTimeout(outer.tx.id, outer.holder.value, waitMs),
188
203
  );
204
+ // Again: the wait is how a sibling outlives the root, and the pin below is no longer this scope's.
205
+ refuseIfFinished(outer.tx.id, outer.outcome.value);
189
206
  const { abort } = outer;
190
207
  // Named, rather than left to the SAVEPOINT below to fail with `25P02`: the statement that broke
191
208
  // the transaction is the one the caller caught, and it is the only one worth reading.
@@ -203,6 +220,7 @@ async function runNested<T>(
203
220
  outer.tx.origin,
204
221
  outer.outcome,
205
222
  abort,
223
+ outer.completing,
206
224
  );
207
225
  // `SAVEPOINT` and `RELEASE` are deliberately uncaught: a savepoint that was never taken means
208
226
  // this scope never opened, and a release that failed means its work is not durable in the outer
@@ -213,6 +231,7 @@ async function runNested<T>(
213
231
  const children = createTurnQueue();
214
232
  const scope: TxState = { ...outer, tx, undos, commits, children, holder: { value: undefined } };
215
233
  const result = await storage.run(scope, () => fn(tx));
234
+ refuseIfFinished(outer.tx.id, outer.outcome.value);
216
235
  // The body swallowed a failed statement. RELEASE would answer `25P02`; the scope is rolled
217
236
  // back below instead, which is the one thing that makes the OUTER transaction usable again.
218
237
  if (abort.value) throw transactionAborted(abort.first);
@@ -227,9 +246,12 @@ async function runNested<T>(
227
246
  // The caller still needs the error that caused the rollback, never the rollback's own — but a
228
247
  // `ROLLBACK TO` that failed is no longer forgotten. The scope's work was NOT undone, so the
229
248
  // root is marked aborted and its COMMIT refuses: committing would store the writes of a scope
230
- // that just told its caller they were rolled back.
249
+ // that just told its caller they were rolled back. Sent only into an open root: the pin of a
250
+ // finished one is back in the pool, and the root's own end already undid this savepoint.
231
251
  try {
232
- await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
252
+ if (outer.outcome.value === 'open') {
253
+ await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
254
+ }
233
255
  // Whatever broke the transaction happened after this savepoint — the SAVEPOINT itself was
234
256
  // accepted — so the server has undone it and statements are accepted again.
235
257
  abort.value = false;
@@ -249,6 +271,8 @@ async function runNested<T>(
249
271
  * connection whose transaction is already gone.
250
272
  */
251
273
  async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOptions): Promise<T> {
274
+ // Before the pin, and before `markScopeWrote`: a scope already abandoned opens nothing.
275
+ refuseAbandoned();
252
276
  const client = options.client ?? baseClient();
253
277
  // A transaction is assumed to write unless it said otherwise, so every read AFTER it in the same
254
278
  // `withReplicaReads` scope is the primary's. The pin below already keeps the transaction's own
@@ -269,7 +293,17 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
269
293
  const commits: (() => void)[] = [];
270
294
  const outcome: TxOutcome = { value: 'open' };
271
295
  const abort: TxAbort = { value: false, first: undefined };
272
- const tx = makeTx(`tx_${nanoid(12)}`, connection, undos, commits, client, outcome, abort);
296
+ const completing = { value: false };
297
+ const tx = makeTx(
298
+ `tx_${nanoid(12)}`,
299
+ connection,
300
+ undos,
301
+ commits,
302
+ client,
303
+ outcome,
304
+ abort,
305
+ completing,
306
+ );
273
307
  // Each attempt gets its own state, and therefore its own `live` — a retry re-runs `fn` against a
274
308
  // transaction that is genuinely new, so the abandoned attempt's stragglers must read as closed.
275
309
  const state: TxState = {
@@ -281,6 +315,7 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
281
315
  savepoints: { value: 0 },
282
316
  live: { value: true },
283
317
  abort,
318
+ completing,
284
319
  children: createTurnQueue(),
285
320
  holder: { value: undefined },
286
321
  };
@@ -289,11 +324,20 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
289
324
  let commitSent = false;
290
325
  try {
291
326
  await connection.execute(raw(beginStatement(options)));
327
+ // Row security's tenant, local to this transaction: inside the BEGIN, so it ends with it.
328
+ const settings = transactionSettings();
329
+ if (settings !== undefined) await setLocal(connection, settings);
292
330
  const result = await storage.run(state, () => fn(tx));
293
331
  // Refused before the COMMIT is sent: the server would answer it `ROLLBACK` with no error. The
294
332
  // funnels read that tag too (`commit-tag.ts`), for an abort this scope's handle never saw.
295
333
  if (abort.value) throw transactionAborted(abort.first);
334
+ // Abandoned between the last statement and here: the `catch` rolls back. A marked root's COMMIT
335
+ // is what makes its keyed replay answer success, so it goes through.
336
+ refuseAbandoned(completing);
296
337
  commitSent = true;
338
+ // Before the COMMIT is on the wire: a straggler sent now would run after it on this connection,
339
+ // outside any transaction. The ROLLBACK's window below is the same hole.
340
+ outcome.value = 'ending';
297
341
  await connection.execute(raw('COMMIT'));
298
342
  // After COMMIT answered, and outside the `catch` below: a failing effect must never be read as
299
343
  // a failed transaction and trigger a ROLLBACK of work the server already made durable.
@@ -303,6 +347,7 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
303
347
  return result;
304
348
  } catch (error) {
305
349
  if (committed) throw error;
350
+ outcome.value = 'ending';
306
351
  // Best-effort: the caller needs the original failure, never the rollback's. A BEGIN that
307
352
  // itself failed opened nothing, so this ROLLBACK is a no-op the server answers with a notice.
308
353
  await connection.execute(raw('ROLLBACK')).catch(() => undefined);
@@ -324,6 +369,16 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
324
369
  }
325
370
  }
326
371
 
372
+ /**
373
+ * Marks the open root as one that must reach its COMMIT even if its scope is abandoned while it
374
+ * runs, and a no-op outside one. `withIdempotency` calls it straight after a reservation it created:
375
+ * the settle rides that COMMIT and the replay answers from it, so one mark decides both.
376
+ */
377
+ export function completeTransaction(): void {
378
+ const state = storage.get();
379
+ if (state?.live.value === true) state.completing.value = true;
380
+ }
381
+
327
382
  export async function withTransaction<T>(
328
383
  fn: (tx: DbTx) => Promise<T>,
329
384
  options: TransactionOptions = {},
@@ -43,10 +43,18 @@ export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
43
43
  { name: 'create index', pattern: /^create\s+index\b/ },
44
44
  { name: 'create unique index', pattern: /^create\s+unique\s+index\b/ },
45
45
  { name: 'drop index', pattern: /^drop\s+index\b/ },
46
+ // `generate-view.ts` writes both kinds, and rebuilds by drop + create — never `or replace`, never
47
+ // `refresh` — so those two stay hand-written and stay counted. A materialized view's indexes are
48
+ // `create [unique] index`, already above.
49
+ { name: 'create view', pattern: /^create\s+view\b/ },
50
+ { name: 'create materialized view', pattern: /^create\s+materialized\s+view\b/ },
51
+ { name: 'drop view', pattern: /^drop\s+view\b/ },
52
+ { name: 'drop materialized view', pattern: /^drop\s+materialized\s+view\b/ },
46
53
  { name: 'add column', pattern: /^alter\s+table\s[\s\S]*?\badd\s+column\b/ },
47
54
  { name: 'drop column', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+column\b/ },
48
55
  { name: 'add constraint', pattern: /^alter\s+table\s[\s\S]*?\badd\s+constraint\b/ },
49
56
  { name: 'drop constraint', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+constraint\b/ },
57
+ { name: 'validate constraint', pattern: /^alter\s+table\s[\s\S]*?\bvalidate\s+constraint\b/ },
50
58
  {
51
59
  name: 'alter column type',
52
60
  pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\btype\b/,
@@ -81,6 +89,28 @@ export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
81
89
  // read them as generatable and let a squash discard the replication setup in silence.
82
90
  pattern: /^alter\s+table\s[\s\S]*?\breplica\s+identity\s+(?:full|default)\b/,
83
91
  },
92
+ // The lifecycle triggers (`generate-triggers.ts`, `generate-history.ts`, `generate-counter.ts`,
93
+ // `generate-append-only.ts`), read on BLANKED text: every name is a quoted identifier (`""`) and
94
+ // every body a dollar quote, so each pattern is the whole statement's shape. Absent until
95
+ // 2026-11-06, so `x db gen` wrote 11 statements its own rail reported. An unquoted, hand-written
96
+ // trigger or function, and an `update` that is not the recount, stay counted.
97
+ {
98
+ name: 'create or replace function … returns trigger',
99
+ pattern:
100
+ /^create\s+or\s+replace\s+function\s+""\s*\(\s*\)\s+returns\s+trigger\s+language\s+plpgsql\s+as\s*$/,
101
+ },
102
+ { name: 'create trigger', pattern: /^create\s+trigger\s+""\s+(?:before|after)\s/ },
103
+ { name: 'drop trigger if exists', pattern: /^drop\s+trigger\s+if\s+exists\s+""\s+on\s+""$/ },
104
+ // The recount's cross-tenant switch, transaction-local (`true`): a setting, never a write.
105
+ {
106
+ name: 'select set_config(…, true)',
107
+ pattern: /^select\s+set_config\s*\(\s*''\s*,\s*''\s*,\s*true\s*\)$/,
108
+ },
109
+ {
110
+ name: 'update … recount',
111
+ pattern:
112
+ /^update\s+""\s+p\s+set\s+""\s*=\s*\(\s*select\s+count\s*\(\s*\*\s*\)::integer\s+from\s+""\s+c\s+where\s/,
113
+ },
84
114
  ];
85
115
 
86
116
  /**