@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
@@ -0,0 +1,88 @@
1
+ // Single responsibility: `X_DB_ROW_SECURITY_DRIFT` — a tenant table and `database.rowSecurity`
2
+ // disagreeing about its policy, read two ways. Against the migrations (no database: the gate's
3
+ // `drift` step) and against the live catalog (`checkDrift`, after `x db migrate`).
4
+
5
+ import type { DriftDifference } from './drift-findings';
6
+ import type { EntityDescriptionLike } from './entity-shape';
7
+ import { DbError } from './errors';
8
+ import { type RowSecurityOption, rowSecurityInput } from './generate-row-security';
9
+ import type { SchemaDescription, TableDescription } from './introspect';
10
+ import { ROW_SECURITY_POLICY } from './row-security';
11
+ import { rowSecurityAfter } from './row-security-plan';
12
+ import { shellInertIdentifier } from './sql';
13
+
14
+ export const ROW_SECURITY_DRIFT_CODE = 'X_DB_ROW_SECURITY_DRIFT';
15
+
16
+ /** `missing`: the flag wants a policy nothing put there. `stray`: one is there the flag does not want. */
17
+ export interface RowSecurityDifference {
18
+ readonly table: string;
19
+ readonly direction: 'missing' | 'stray';
20
+ }
21
+
22
+ /**
23
+ * The flag and the tenant columns against what the newest snapshot recorded — the same input
24
+ * `x db gen` plans from, so the gate never expects a policy the generator would not write.
25
+ */
26
+ export function rowSecurityDifferences(
27
+ entities: readonly EntityDescriptionLike[],
28
+ recorded: SchemaDescription,
29
+ option: RowSecurityOption,
30
+ ): readonly RowSecurityDifference[] {
31
+ const input = rowSecurityInput(entities, recorded, option);
32
+ const wanted = rowSecurityAfter(input);
33
+ return [
34
+ ...[...wanted]
35
+ .filter((table) => !input.recorded.has(table))
36
+ .map((table) => ({ table, direction: 'missing' as const })),
37
+ ...[...input.recorded]
38
+ .filter((table) => !wanted.has(table))
39
+ .map((table) => ({ table, direction: 'stray' as const })),
40
+ ].sort((a, b) => (a.table < b.table ? -1 : 1));
41
+ }
42
+
43
+ /** The migrations' reading as an error. The repair is the generation the flag is waiting on. */
44
+ export const rowSecurityDrift = (difference: RowSecurityDifference): DbError =>
45
+ new DbError({
46
+ code: ROW_SECURITY_DRIFT_CODE,
47
+ cause:
48
+ difference.direction === 'missing'
49
+ ? `database.rowSecurity is on and no migration gave tenant table "${difference.table}" its policy: a raw sql read of it still sees every tenant's rows`
50
+ : `database.rowSecurity is off and a migration left tenant table "${difference.table}" its policy: every read without a tenant set sees no rows`,
51
+ fix: 'x db gen "row security"',
52
+ meta: { table: difference.table, direction: difference.direction },
53
+ });
54
+
55
+ /** Where the statements are, as a command that prints them: the ledger already holds the migration. */
56
+ const statementsOf = (table: string): string => {
57
+ const name = shellInertIdentifier(table);
58
+ const pattern = name === null ? ROW_SECURITY_POLICY : `on ${name}`;
59
+ return `grep -n '${pattern}' packages/db/migrations/*.sql`;
60
+ };
61
+
62
+ /**
63
+ * The catalog's reading, judged only where both halves were asked (`compareAppendOnly`'s rule):
64
+ * a snapshot that predates the field, or a description nobody introspected, says nothing.
65
+ * `policyNames` holds only an enabled AND forced table's policies, so a disabled or unforced
66
+ * table reads as missing — an owner or nobody would be bound by it.
67
+ */
68
+ export function compareRowSecurity(
69
+ live: TableDescription,
70
+ expected: TableDescription,
71
+ ): DriftDifference[] {
72
+ if (live.policyNames === undefined) return [];
73
+ const present = live.policyNames.includes(ROW_SECURITY_POLICY);
74
+ const wanted = expected.rowSecurity === true;
75
+ if (present === wanted) return [];
76
+ const table = live.name;
77
+ return [
78
+ {
79
+ kind: wanted ? 'missing-row-security' : 'unexpected-row-security',
80
+ table,
81
+ column: null,
82
+ cause: wanted
83
+ ? `table "${table}" should carry the forced tenant policy ${ROW_SECURITY_POLICY} and does not, or its row security is disabled or not forced: raw sql sees every tenant's rows`
84
+ : `table "${table}" carries the tenant policy ${ROW_SECURITY_POLICY} no migration recorded`,
85
+ fix: `${statementsOf(table)} # run the row-security statements it prints against this database with psql "$DATABASE_URL", then x db migrate, which re-checks`,
86
+ },
87
+ ];
88
+ }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: whether the lifecycle triggers a migration installed — history, its guard,
2
+ // counters — are still enabled on the table, and whether a `retain` table became partitioned. The
3
+ // repository never sees either, so only the catalog can say. `drift-append-only.ts` is the model.
4
+
5
+ import { byHand, type DriftDifference, pathTo, repair } from './drift-findings';
6
+ import type { TableDescription, TriggerRecord } from './introspect';
7
+ import { shellInertIdentifier } from './sql';
8
+
9
+ /** The codes `driftError` raises for these kinds, exported so the tests assert the one literal. */
10
+ export const LIFECYCLE_DRIFT_CODE = 'X_LIFECYCLE_TRIGGER_MISSING';
11
+ export const RETAIN_DRIFT_CODE = 'X_ENTITY_RETAIN_INVALID';
12
+
13
+ /** The fix is the statements `x db gen` wrote, against THIS database: the ledger already holds them. */
14
+ function missingTrigger(live: TableDescription, trigger: TriggerRecord): DriftDifference {
15
+ const path = pathTo(live.schema);
16
+ return {
17
+ kind: 'missing-lifecycle-trigger',
18
+ table: live.name,
19
+ column: null,
20
+ cause:
21
+ `table "${live.name}" was given trigger ${trigger.name} by a migration, and it is missing or ` +
22
+ 'disabled — writes here no longer record history or keep a counter',
23
+ fix:
24
+ path === null || shellInertIdentifier(live.name) === null
25
+ ? byHand(`re-create trigger ${trigger.name} as the newest migration that names it does`)
26
+ : repair(path, trigger.install.join(' ')),
27
+ };
28
+ }
29
+
30
+ function partitionedRetain(live: TableDescription): DriftDifference {
31
+ return {
32
+ kind: 'retain-partitioned',
33
+ table: live.name,
34
+ column: null,
35
+ cause:
36
+ `table "${live.name}" declares retain and the catalog holds it partitioned: a partitioned ` +
37
+ "table's retention is detaching its old partitions, never a row-by-row sweep",
38
+ fix: `x db gen "drop retain" # after removing retain from the entity on ${live.name}; detach old partitions in a task`,
39
+ };
40
+ }
41
+
42
+ /** Judged only where both halves were asked, exactly as `compareAppendOnly` reads its pair. */
43
+ export function compareTriggers(
44
+ live: TableDescription,
45
+ expected: TableDescription,
46
+ ): DriftDifference[] {
47
+ const out: DriftDifference[] = [];
48
+ if (expected.retain === true && live.partitioned === true) out.push(partitionedRetain(live));
49
+ if (live.triggerNames === undefined) return out;
50
+ for (const trigger of expected.triggers ?? []) {
51
+ if (!live.triggerNames.includes(trigger.name)) out.push(missingTrigger(live, trigger));
52
+ }
53
+ return out;
54
+ }
package/src/drift.ts CHANGED
@@ -5,22 +5,27 @@
5
5
 
6
6
  import { baseClient, type DbClient } from './client';
7
7
  import { APPEND_ONLY_DRIFT_CODE, compareAppendOnly } from './drift-append-only';
8
- import type { DriftDifference } from './drift-findings';
8
+ import type { DriftDifference, DriftKind } from './drift-findings';
9
9
  import {
10
10
  changedColumn,
11
11
  changedForeignKey,
12
- changedIndex,
13
12
  changedPrimaryKey,
14
13
  missingCheck,
15
- missingColumn,
16
- missingForeignKey,
17
- missingIndex,
18
- missingTable,
14
+ notValidConstraint,
19
15
  unexpectedColumn,
20
16
  unexpectedTable,
21
17
  unknownSchema,
22
18
  } from './drift-findings';
23
- import { DbError } from './errors';
19
+ import {
20
+ changedIndex,
21
+ missingColumn,
22
+ missingForeignKey,
23
+ missingIndex,
24
+ missingTable,
25
+ } from './drift-missing';
26
+ import { compareRowSecurity, ROW_SECURITY_DRIFT_CODE } from './drift-row-security';
27
+ import { compareTriggers, LIFECYCLE_DRIFT_CODE, RETAIN_DRIFT_CODE } from './drift-triggers';
28
+ import { DbError, type DbErrorCode } from './errors';
24
29
  import { foreignKeyTarget, onDeleteRule } from './foreign-key';
25
30
  import { indexMethodOf } from './index-method';
26
31
  import {
@@ -76,7 +81,7 @@ function compareIndexes(live: TableDescription, expected: TableDescription): Dri
76
81
  for (const index of expected.indexes) {
77
82
  const counterpart = present.get(index.name);
78
83
  if (counterpart === undefined) {
79
- differences.push(missingIndex(live.name, index.name));
84
+ differences.push(missingIndex(live.schema, live.name, index));
80
85
  continue;
81
86
  }
82
87
  // The method first, and before the column list: a GIN index and a btree over the same column
@@ -86,27 +91,47 @@ function compareIndexes(live: TableDescription, expected: TableDescription): Dri
86
91
  // catalog reports as `btree` agree instead of reporting drift on a correct database.
87
92
  if (indexMethodOf(counterpart) !== indexMethodOf(index)) {
88
93
  differences.push(
89
- changedIndex(live.name, index.name, `is a ${indexMethodOf(counterpart)} index`),
94
+ changedIndex(
95
+ live.schema,
96
+ live.name,
97
+ index,
98
+ counterpart,
99
+ `is a ${indexMethodOf(counterpart)} index`,
100
+ ),
90
101
  );
91
102
  continue;
92
103
  }
93
104
  if (counterpart.columns.join(',') !== index.columns.join(',')) {
94
105
  differences.push(
95
- changedIndex(live.name, index.name, `covers (${counterpart.columns.join(', ')})`),
106
+ changedIndex(
107
+ live.schema,
108
+ live.name,
109
+ index,
110
+ counterpart,
111
+ `covers (${counterpart.columns.join(', ')})`,
112
+ ),
96
113
  );
97
114
  continue;
98
115
  }
99
116
  if (counterpart.unique !== index.unique) {
100
117
  differences.push(
101
- changedIndex(live.name, index.name, counterpart.unique ? 'is unique' : 'is not unique'),
118
+ changedIndex(
119
+ live.schema,
120
+ live.name,
121
+ index,
122
+ counterpart,
123
+ counterpart.unique ? 'is unique' : 'is not unique',
124
+ ),
102
125
  );
103
126
  continue;
104
127
  }
105
128
  if ((counterpart.order ?? 'asc') !== (index.order ?? 'asc')) {
106
129
  differences.push(
107
130
  changedIndex(
131
+ live.schema,
108
132
  live.name,
109
- index.name,
133
+ index,
134
+ counterpart,
110
135
  counterpart.order === 'desc' ? 'is descending' : 'is ascending',
111
136
  ),
112
137
  );
@@ -115,8 +140,10 @@ function compareIndexes(live: TableDescription, expected: TableDescription): Dri
115
140
  if ((counterpart.where === null) !== (index.where === null)) {
116
141
  differences.push(
117
142
  changedIndex(
143
+ live.schema,
118
144
  live.name,
119
- index.name,
145
+ index,
146
+ counterpart,
120
147
  counterpart.where === null ? 'covers every row' : 'is partial',
121
148
  ),
122
149
  );
@@ -149,7 +176,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
149
176
  for (const key of expected.foreignKeys) {
150
177
  const counterpart = present.get(foreignKeyTarget(key));
151
178
  if (counterpart === undefined) {
152
- differences.push(missingForeignKey(live.name, key));
179
+ differences.push(missingForeignKey(live.schema, live.name, key));
153
180
  continue;
154
181
  }
155
182
  if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
@@ -186,6 +213,25 @@ function compareChecks(live: TableDescription, expected: TableDescription): Drif
186
213
  .map((check) => missingCheck(live.schema, live.name, check));
187
214
  }
188
215
 
216
+ /**
217
+ * A declared constraint the catalog holds but never validated. Only the declared side, as everywhere
218
+ * here: a hand-written `NOT VALID` no snapshot records is the app's decision. Foreign keys are read
219
+ * by where they point, the identity `compareForeignKeys` uses, so a name the catalog holds and the
220
+ * snapshot does not still counts.
221
+ */
222
+ function compareValidated(live: TableDescription, expected: TableDescription): DriftDifference[] {
223
+ const unvalidated = new Set(live.notValidNames ?? []);
224
+ if (unvalidated.size === 0) return [];
225
+ const declared = new Set((expected.checks ?? []).map((check) => check.name));
226
+ const pointed = new Set(expected.foreignKeys.map(foreignKeyTarget));
227
+ for (const key of live.foreignKeys) {
228
+ if (pointed.has(foreignKeyTarget(key))) declared.add(key.name);
229
+ }
230
+ return [...unvalidated]
231
+ .filter((name) => declared.has(name))
232
+ .map((name) => notValidConstraint(live.schema, live.name, name));
233
+ }
234
+
189
235
  /**
190
236
  * The two key lists, compared in ORDER. The comment that used to stand here said a key on one side
191
237
  * only "is a difference the key comparison owns" — and no key comparison existed, so a table
@@ -213,7 +259,7 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
213
259
  for (const column of expected.columns) {
214
260
  const counterpart = liveColumns.get(column.name);
215
261
  if (counterpart === undefined) {
216
- differences.push(missingColumn(live.name, column.name));
262
+ differences.push(missingColumn(live.schema, live.name, column));
217
263
  continue;
218
264
  }
219
265
  // Nullability, not the type: the catalog and a snapshot spell types differently often enough
@@ -228,12 +274,23 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
228
274
  differences.push(...compareIndexes(live, expected));
229
275
  differences.push(...compareChecks(live, expected));
230
276
  differences.push(...compareForeignKeys(live, expected));
277
+ differences.push(...compareValidated(live, expected));
231
278
  differences.push(...compareAppendOnly(live, expected));
279
+ differences.push(...compareRowSecurity(live, expected));
280
+ differences.push(...compareTriggers(live, expected));
232
281
  return differences;
233
282
  }
234
283
 
235
- /** Pure and total: the same inputs always produce the same ordered report. */
236
- export function diffSchema(live: SchemaDescription, expected: SchemaDescription): DriftReport {
284
+ /**
285
+ * Pure and total: the same inputs always produce the same ordered report. `schema` is where the
286
+ * live side was read — a missing table has no live description to say so, and its repair has to
287
+ * create it there. Default `public`, `introspectSchema()`'s own.
288
+ */
289
+ export function diffSchema(
290
+ live: SchemaDescription,
291
+ expected: SchemaDescription,
292
+ schema = 'public',
293
+ ): DriftReport {
237
294
  const differences: DriftDifference[] = [];
238
295
  for (const table of live.tables) {
239
296
  const counterpart = findTable(expected, table.name);
@@ -241,7 +298,9 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
241
298
  else differences.push(...compareTable(table, counterpart));
242
299
  }
243
300
  for (const table of expected.tables) {
244
- if (findTable(live, table.name) === undefined) differences.push(missingTable(table.name));
301
+ if (findTable(live, table.name) === undefined) {
302
+ differences.push(missingTable(schema, table.name, table.columns, table.primaryKey));
303
+ }
245
304
  }
246
305
  differences.sort((a, b) => {
247
306
  if (a.table !== b.table) return a.table < b.table ? -1 : 1;
@@ -251,10 +310,18 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
251
310
  return { ok: differences.length === 0, differences };
252
311
  }
253
312
 
313
+ /** The kinds that carry their own code — the guarantee each names is the entity's, not a column's. */
314
+ const OWN_CODE: ReadonlyMap<DriftKind, DbErrorCode> = new Map<DriftKind, DbErrorCode>([
315
+ ['missing-append-only-trigger', APPEND_ONLY_DRIFT_CODE],
316
+ ['missing-row-security', ROW_SECURITY_DRIFT_CODE],
317
+ ['unexpected-row-security', ROW_SECURITY_DRIFT_CODE],
318
+ ['missing-lifecycle-trigger', LIFECYCLE_DRIFT_CODE],
319
+ ['retain-partitioned', RETAIN_DRIFT_CODE],
320
+ ]);
321
+
254
322
  export function driftError(difference: DriftDifference): DbError {
255
323
  return new DbError({
256
- // One kind carries its own code — the guarantee it names is the entity's, not a column's.
257
- code: difference.kind === 'missing-append-only-trigger' ? APPEND_ONLY_DRIFT_CODE : 'X_DB_DRIFT',
324
+ code: OWN_CODE.get(difference.kind) ?? 'X_DB_DRIFT',
258
325
  cause: difference.cause,
259
326
  fix: difference.fix,
260
327
  meta: { kind: difference.kind, table: difference.table, column: difference.column },
@@ -360,5 +427,5 @@ export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
360
427
  client,
361
428
  ...(options.schema === undefined ? {} : { schema: options.schema }),
362
429
  });
363
- return diffSchema(appTables(live), expected);
430
+ return diffSchema(appTables(live), expected, options.schema);
364
431
  }
@@ -45,6 +45,16 @@ export interface ColumnDescriptionLike {
45
45
  * is REPORTED as unrendered rather than dropped (`unrendered.ts`).
46
46
  */
47
47
  readonly default?: ColumnDefaultLike | undefined;
48
+ /**
49
+ * Both ends of this `references()` are tenant-scoped, so the key is composite: `(column, this) ->
50
+ * target (targetColumn, its key)`. Absent or `null` is the plain key every older description
51
+ * declares. `@ultimat3/entity` decides it (`describeReferences`), and only the two physical names
52
+ * cross, because the rest of the key is already on this column.
53
+ */
54
+ readonly sameTenant?:
55
+ | { readonly column: string; readonly targetColumn: string }
56
+ | null
57
+ | undefined;
48
58
  }
49
59
 
50
60
  /**
@@ -125,4 +135,61 @@ export interface EntityDescriptionLike {
125
135
  * Optional for the reason `invariants` is, and absent reads as "rows may change".
126
136
  */
127
137
  readonly appendOnly?: boolean | undefined;
138
+ /**
139
+ * `entity({ retired: ['legacy'] })`: physical columns old pods may still read and this release
140
+ * does not (`retired.ts`). Absent is none, for the reason `invariants` is optional.
141
+ */
142
+ readonly retired?: readonly string[] | undefined;
143
+ /**
144
+ * `entity(name, { view: { sql } })`: a relation Postgres derives, emitted by `generate-view.ts`
145
+ * and handed to no table arm. Absent is a table, for the reason `invariants` is optional.
146
+ */
147
+ readonly view?: { readonly sql: string; readonly materialized?: true | undefined } | undefined;
148
+ /**
149
+ * `entity({ history: true })`: every row change is recorded into `history.table` by a trigger
150
+ * (`generate-history.ts`). Absent is no history, for the reason `invariants` is optional.
151
+ */
152
+ readonly history?: HistoryDescriptionLike | undefined;
153
+ /**
154
+ * `references(…, { countedIn })` on a child key: counters the database keeps on this table
155
+ * (`generate-counter.ts`). Absent is none.
156
+ */
157
+ readonly counts?: readonly CounterDescriptionLike[] | undefined;
158
+ /** `entity({ retain })`: rows older than the window are swept by `purge()`. Absent is kept. */
159
+ readonly retain?: RetainDescriptionLike | undefined;
160
+ }
161
+
162
+ /** Where a table's row images go, and what its trigger needs to write one. Physical names. */
163
+ export interface HistoryDescriptionLike {
164
+ /** The history table: `<table>_history`, an entity of its own. */
165
+ readonly table: string;
166
+ /** The parent's single primary-key column, recorded as `row_id`. */
167
+ readonly key: string;
168
+ /** The parent's tenant column, copied onto every history row, or `null`. */
169
+ readonly tenant: string | null;
170
+ /** Columns whose change alone records nothing: the counters the database keeps. */
171
+ readonly ignore: readonly string[];
172
+ /** `int8`/`numeric` columns the image stores as their digits, never a JSON number. */
173
+ readonly text?: readonly string[] | undefined;
174
+ /** `timestamptz` columns the image stores as a UTC ISO string, whatever the session zone. */
175
+ readonly instants?: readonly string[] | undefined;
176
+ }
177
+
178
+ /** One counter, physical names: `<this table>.<column>` counts `<table>` rows by `<references>`. */
179
+ export interface CounterDescriptionLike {
180
+ readonly column: string;
181
+ /** The parent's key column the child's foreign key points at. */
182
+ readonly key: string;
183
+ /** The child table the trigger sits on. */
184
+ readonly table: string;
185
+ /** The child's foreign-key column. */
186
+ readonly references: string;
187
+ /** The child's soft-delete column: a stamped row is not counted. `null` when it has none. */
188
+ readonly softDelete: string | null;
189
+ }
190
+
191
+ /** `retain: { after, on }`, with `on` physical. */
192
+ export interface RetainDescriptionLike {
193
+ readonly after: string;
194
+ readonly on: string;
128
195
  }
package/src/errors.ts CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  stringField,
12
12
  UltimateError,
13
13
  } from '@ultimat3/core';
14
- import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
14
+ import { IDLE_IN_TRANSACTION_FIX, poolerOptionsRefusedFix } from './pooler-fix';
15
+ import { type DbSqlStateCode, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
15
16
 
16
17
  /**
17
18
  * Codes this package declares and owns. `X_DB_DRIFT` is db's: it is a statement about migrations
@@ -44,6 +45,19 @@ export const DB_OWNED_ERROR_CODES = [
44
45
  'X_APPEND_ONLY_TRIGGER_MISSING',
45
46
  'X_MIGRATION_APPEND_ONLY_BACKFILL',
46
47
  'X_MIGRATION_CONCURRENT_IN_TRANSACTION',
48
+ 'X_DB_TRANSACTION_CLOSED',
49
+ 'X_MIGRATION_ROLLING_UNSAFE',
50
+ 'X_DB_POOLER_OPTIONS_REFUSED',
51
+ 'X_DB_POOLER_SESSION_REQUIRED',
52
+ 'X_DB_STATEMENT_BOUND_LOST',
53
+ 'X_DB_STATEMENT_BOUND_INVALID',
54
+ 'X_MIGRATION_CONCURRENT_PARTITIONED',
55
+ 'X_DB_ROW_SECURITY_BYPASSED',
56
+ 'X_DB_ROW_SECURITY_DRIFT',
57
+ 'X_KEYSET_NULL_KEY',
58
+ 'X_MIGRATION_ROLLBACK_DESTRUCTIVE',
59
+ 'X_ENTITY_RETAIN_INVALID',
60
+ 'X_LIFECYCLE_TRIGGER_MISSING',
47
61
  ] as const;
48
62
 
49
63
  /**
@@ -97,6 +111,26 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
97
111
  'a NOT NULL column added to an append-only table needs a default',
98
112
  X_MIGRATION_CONCURRENT_IN_TRANSACTION:
99
113
  'a concurrent index statement is in a migration that runs inside a transaction',
114
+ X_DB_TRANSACTION_CLOSED: 'statement on a finished transaction',
115
+ X_MIGRATION_ROLLING_UNSAFE:
116
+ 'this drop or rename would break the pods still running the old release',
117
+ X_DB_POOLER_OPTIONS_REFUSED:
118
+ 'the connection pooler refused the libpq options a pool connects with',
119
+ X_DB_POOLER_SESSION_REQUIRED:
120
+ 'this role needs one backend per session, and a transaction pooler gives it none',
121
+ X_DB_STATEMENT_BOUND_LOST: "the session's statement bound is not the role's",
122
+ X_DB_STATEMENT_BOUND_INVALID:
123
+ 'a statement bound is not a whole positive number of ms under the role bound',
124
+ X_MIGRATION_CONCURRENT_PARTITIONED: 'a concurrent index build names a partitioned table',
125
+ X_DB_ROW_SECURITY_BYPASSED: 'row security is on and the serving role is exempt from it',
126
+ X_DB_ROW_SECURITY_DRIFT: 'a tenant table and database.rowSecurity disagree about its policy',
127
+ X_KEYSET_NULL_KEY: 'a keyset page holds a null in one of its sort keys',
128
+ X_MIGRATION_ROLLBACK_DESTRUCTIVE:
129
+ 'a rollback would serve an older build over a destructive migration',
130
+ X_ENTITY_RETAIN_INVALID:
131
+ 'entity({ retain }) has no window, sits beside appendOnly, or is on a partitioned table',
132
+ X_LIFECYCLE_TRIGGER_MISSING:
133
+ 'a history or counter trigger the migrations created is missing from the database',
100
134
  };
101
135
 
102
136
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -200,6 +234,18 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
200
234
  sourceError,
201
235
  });
202
236
 
237
+ /**
238
+ * No answer past the role's bound plus the grace (`pool-sweep.ts`), so the server's own bound had to
239
+ * have answered and the path is dead. The same uncertainty as a connection closed mid-statement,
240
+ * which is why the cause says it. `X_DB_UNAVAILABLE` is deliberately left out of `DB_ERROR_RETRY`
241
+ * (unclassified, not terminal), so a job worker RE-RUNS the attempt: an autocommit write that landed
242
+ * runs again. At least once, as every job is: the write must be idempotent or inside a transaction.
243
+ */
244
+ export const statementUnanswered = (excerpt: string, boundMs: number): DbError =>
245
+ dbUnavailable(
246
+ `no answer within ${boundMs}ms to: ${excerpt} — past the statement bound, so the path is dead and this pool is replaced; if this was an autocommit write it may have landed`,
247
+ );
248
+
203
249
  /**
204
250
  * One `fix:` per classified SQLSTATE, written once. Every one names a command that exists or an
205
251
  * edit the reader can make — a `23505` telling an operator the database is unreachable is the
@@ -218,14 +264,14 @@ const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
218
264
  'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
219
265
  X_DB_FOREIGN_KEY_VIOLATION:
220
266
  'insert the row {constraint} points at first, in the same withTransaction(...) — ' +
221
- 'or drop the write, because the parent it names is gone',
267
+ "or drop the write, because the parent it names is gone or is not this tenant's",
222
268
  X_DB_SERIALIZATION_FAILURE:
223
269
  'withTransaction(fn, { retry: 3 }) # fn re-runs from the top, so it must be idempotent',
224
270
  X_DB_STATEMENT_TIMEOUT:
225
271
  'add the index this statement needs to the entity (indexes: [...]), then: x db gen "add index"',
226
272
  X_DB_LOCK_TIMEOUT:
227
- `psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'"` +
228
- ' # end the blocker, then re-run the statement',
273
+ "x db diagnostics --json | jq '.data.blockers.rows[] | {pid, blockedBy, blockerState}'" +
274
+ ' # end the blockedBy session or wait for it, then re-run the statement',
229
275
  X_DB_POOL_EXHAUSTED:
230
276
  'set DATABASE_POOL_MAX below max_connections / replicas (per-role default: POOL_PROFILES), ' +
231
277
  'or cut the replica count',
@@ -256,6 +302,24 @@ export const driverError = (statement: string, sourceError: unknown): DbError =>
256
302
  // `X_DB_UNAVAILABLE` said "set DATABASE_URL" to a developer whose database was fine.
257
303
  const code = sqlStateCode(sourceError) ?? 'X_DB_STATEMENT_FAILED';
258
304
  const constraint = stringField(sourceError, 'constraint');
305
+ // `08P01` is the protocol's general complaint, so the pooler's own sentence is what decides, and
306
+ // this one reached no statement: it is the connection every statement of the role would use.
307
+ if (
308
+ state === SQLSTATE.protocolViolation &&
309
+ renderThrowable(sourceError).includes('unsupported startup parameter')
310
+ ) {
311
+ // The identifier characters only: the server's text runs on past the name.
312
+ const parameter = /unsupported startup parameter(?: in options)?: ([A-Za-z0-9_.]+)/.exec(
313
+ stringField(sourceError, 'message') ?? renderThrowable(sourceError),
314
+ )?.[1];
315
+ return new DbError({
316
+ code: 'X_DB_POOLER_OPTIONS_REFUSED',
317
+ cause: `[SQLSTATE ${state}] ${renderThrowable(sourceError)} — statement: ${statement}`,
318
+ fix: poolerOptionsRefusedFix(parameter),
319
+ meta: { sqlState: state },
320
+ sourceError,
321
+ });
322
+ }
259
323
  return new DbError({
260
324
  code,
261
325
  // The server's SQLSTATE and message FIRST, the statement after: a cause is rendered on one
@@ -268,9 +332,11 @@ export const driverError = (statement: string, sourceError: unknown): DbError =>
268
332
  // ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
269
333
  // `{constraint}` back into the fix line an author is meant to paste.
270
334
  fix:
271
- code === 'X_DB_STATEMENT_FAILED'
272
- ? `psql "$DATABASE_URL" -c "<the statement in cause>" # SQLSTATE ${state} is the server's verdict on it: fix the SQL or the data it names, not the connection`
273
- : SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
335
+ state === SQLSTATE.idleInTransactionTimeout
336
+ ? IDLE_IN_TRANSACTION_FIX
337
+ : code === 'X_DB_STATEMENT_FAILED'
338
+ ? `psql "$DATABASE_URL" -c "<the statement in cause>" # SQLSTATE ${state} is the server's verdict on it: fix the SQL or the data it names, not the connection`
339
+ : SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
274
340
  meta: {
275
341
  sqlState: state,
276
342
  ...(constraint === undefined ? {} : { constraint }),
@@ -320,9 +386,11 @@ export const poolAcquireTimeout = (waitedMs: number, max: number): DbError =>
320
386
  export const poolMaxInvalid = (received: string): DbError =>
321
387
  new DbError({
322
388
  code: 'X_ENV_MISSING',
323
- cause: `DATABASE_POOL_MAX is ${JSON.stringify(received)}, which is not a positive integer`,
389
+ // `describeValue`, never the value, in the cause AND meta: an operator who pasted a connection
390
+ // string here would read its password in the boot log and every `--json` payload.
391
+ cause: `DATABASE_POOL_MAX is ${describeValue(received)}, which is not a positive integer`,
324
392
  fix: 'DATABASE_POOL_MAX=20 # a whole number of connections per process, or unset it',
325
- meta: { received },
393
+ meta: { received: describeValue(received) },
326
394
  });
327
395
 
328
396
  /**
@@ -1,7 +1,7 @@
1
- // Single responsibility: the one way to declare that a loop of statements is deliberate, so a
2
- // statement-level diagnostic reports the loops nobody argued for and stays quiet about the rest.
3
- // A scope with a written reason — never a comment pragma and never a config list of exempt call
4
- // sites (axiom 1), because both put the argument somewhere other than the loop it defends.
1
+ // Single responsibility: the one way to declare a loop of statements deliberate, or a statement a
2
+ // whole-table read, so a diagnostic reports only what nobody argued for. A scope with a written
3
+ // reason — never a comment pragma nor a config list of exempt call sites (axiom 1): both put the
4
+ // argument somewhere other than the statement it defends.
5
5
 
6
6
  // The reason has to outlive every `await` inside the scope and a module-scope variable would be
7
7
  // shared by two concurrent loops, so it needs an async context — opened through core's one lazy
@@ -52,3 +52,33 @@ export function expectedQueryLoop<T>(reason: string, fn: () => T): T {
52
52
  export function expectedQueryLoopReason(): string | undefined {
53
53
  return storage.get();
54
54
  }
55
+
56
+ const scans = asyncContext<string>('the expected-seq-scan reason');
57
+
58
+ /**
59
+ * Run `fn` with every statement it issues marked as one that must read its table whole, carrying
60
+ * `reason`. The plan sweep `x exercise` runs reads it off each statement it saw, and lists the
61
+ * statement as exempt with the reason instead of `X_QUERY_UNINDEXED` — for a hand-written report
62
+ * over every row, never for a lookup that lacks its index. A table that is small by nature says so
63
+ * once, on the entity: `entity('plans', { scan: '<why>' })`. Same shape as `expectedQueryLoop`:
64
+ * required, non-blank, innermost wins, and a verdict suppressed — the statement still runs.
65
+ *
66
+ * ```ts
67
+ * await expectedSeqScan('the nightly digest reads every subscription once', () =>
68
+ * db().query(sql`select id, email from subscriptions where active`),
69
+ * );
70
+ * ```
71
+ */
72
+ export function expectedSeqScan<T>(reason: string, fn: () => T): T {
73
+ assert(
74
+ reason.trim() !== '',
75
+ 'expectedSeqScan() was given a blank reason, so the scan it exempts carries no argument',
76
+ "pass why the statement must read the whole table: expectedSeqScan('the nightly digest reads every row once', fn)",
77
+ );
78
+ return scans.run(reason, fn);
79
+ }
80
+
81
+ /** The innermost `expectedSeqScan` reason in force, or `undefined` outside every scope. */
82
+ export function expectedSeqScanReason(): string | undefined {
83
+ return scans.get();
84
+ }