@ultimat3/db 29.3.2 → 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 (98) hide show
  1. package/CLAUDE.md +78 -116
  2. package/README.md +199 -16
  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 +157 -0
  14. package/src/concurrent-plan.ts +294 -0
  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 +21 -35
  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 +75 -0
  30. package/src/errors.ts +80 -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 +99 -0
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +128 -89
  43. package/src/index-ddl.ts +23 -35
  44. package/src/index-plan.ts +35 -14
  45. package/src/index.ts +136 -6
  46. package/src/introspect-catalog.ts +26 -6
  47. package/src/introspect-rows.ts +82 -0
  48. package/src/introspect.ts +108 -50
  49. package/src/keyset.ts +182 -0
  50. package/src/lifecycle-errors.ts +15 -0
  51. package/src/local-settings.ts +38 -0
  52. package/src/marker-line.ts +47 -0
  53. package/src/migrate-lock-retry.ts +60 -0
  54. package/src/migrate-rollback.ts +45 -6
  55. package/src/migrate.ts +67 -69
  56. package/src/migration-errors.ts +48 -1
  57. package/src/migration-ledger.ts +38 -4
  58. package/src/migration-step.ts +192 -0
  59. package/src/partition-order.ts +56 -0
  60. package/src/partitioned-tables.ts +24 -0
  61. package/src/pool-gate.ts +120 -0
  62. package/src/pool-listeners.ts +143 -0
  63. package/src/pool-pins.ts +68 -0
  64. package/src/pool-profile.ts +23 -7
  65. package/src/pool-reserve.ts +11 -4
  66. package/src/pool-retire.ts +22 -0
  67. package/src/pool-sweep.ts +115 -0
  68. package/src/pool-timer.ts +10 -0
  69. package/src/pooler-fix.ts +53 -0
  70. package/src/pooler.ts +161 -0
  71. package/src/read-transaction.ts +59 -0
  72. package/src/readonly-query.ts +4 -4
  73. package/src/replica-client.ts +54 -22
  74. package/src/replica-position.ts +89 -0
  75. package/src/replica-scope.ts +25 -4
  76. package/src/retired.ts +131 -0
  77. package/src/retype-dependents.ts +2 -0
  78. package/src/rolling-errors.ts +97 -0
  79. package/src/rolling.ts +212 -0
  80. package/src/row-secure-client.ts +54 -0
  81. package/src/row-security-catalog.ts +26 -0
  82. package/src/row-security-migrator.ts +48 -0
  83. package/src/row-security-plan.ts +58 -0
  84. package/src/row-security-role.ts +60 -0
  85. package/src/row-security-roles.ts +64 -0
  86. package/src/row-security.ts +59 -0
  87. package/src/same-tenant.ts +173 -0
  88. package/src/schema-dump-policies.ts +26 -0
  89. package/src/schema-dump-table.ts +21 -6
  90. package/src/schema-dump.ts +13 -2
  91. package/src/snapshot-parse.ts +42 -2
  92. package/src/sqlstate.ts +4 -0
  93. package/src/statement-bound.ts +82 -0
  94. package/src/transaction-errors.ts +14 -0
  95. package/src/transaction-guard.ts +40 -0
  96. package/src/transaction-settings.ts +24 -0
  97. package/src/transaction.ts +74 -19
  98. package/src/ungeneratable.ts +30 -0
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
  /**
@@ -97,6 +107,14 @@ export interface IndexDescriptionLike {
97
107
  * the declarer; this package cannot import it, so the method crosses the seam structurally.
98
108
  */
99
109
  readonly using?: IndexMethod | undefined;
110
+ /**
111
+ * `indexes: [{ on, concurrently: true }]`: build it with `create index concurrently` in a
112
+ * migration of its own that runs OUTSIDE a transaction (`concurrent-index.ts`), so a hot table
113
+ * keeps taking writes while it builds. HOW the index is built, never WHAT it is — so it is not
114
+ * part of the name, not recorded in a snapshot and never a reason to rebuild one. Absent is the
115
+ * plain, transactional `create index` every index was before it existed.
116
+ */
117
+ readonly concurrently?: boolean | undefined;
100
118
  }
101
119
 
102
120
  /** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
@@ -117,4 +135,61 @@ export interface EntityDescriptionLike {
117
135
  * Optional for the reason `invariants` is, and absent reads as "rows may change".
118
136
  */
119
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;
120
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
@@ -43,6 +44,20 @@ export const DB_OWNED_ERROR_CODES = [
43
44
  'X_DB_SIBLING_SCOPE_TIMEOUT',
44
45
  'X_APPEND_ONLY_TRIGGER_MISSING',
45
46
  'X_MIGRATION_APPEND_ONLY_BACKFILL',
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',
46
61
  ] as const;
47
62
 
48
63
  /**
@@ -94,6 +109,28 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
94
109
  X_APPEND_ONLY_TRIGGER_MISSING: 'an append-only table has lost its refusing trigger',
95
110
  X_MIGRATION_APPEND_ONLY_BACKFILL:
96
111
  'a NOT NULL column added to an append-only table needs a default',
112
+ X_MIGRATION_CONCURRENT_IN_TRANSACTION:
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',
97
134
  };
98
135
 
99
136
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -197,6 +234,18 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
197
234
  sourceError,
198
235
  });
199
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
+
200
249
  /**
201
250
  * One `fix:` per classified SQLSTATE, written once. Every one names a command that exists or an
202
251
  * edit the reader can make — a `23505` telling an operator the database is unreachable is the
@@ -215,14 +264,14 @@ const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
215
264
  'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
216
265
  X_DB_FOREIGN_KEY_VIOLATION:
217
266
  'insert the row {constraint} points at first, in the same withTransaction(...) — ' +
218
- '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",
219
268
  X_DB_SERIALIZATION_FAILURE:
220
269
  'withTransaction(fn, { retry: 3 }) # fn re-runs from the top, so it must be idempotent',
221
270
  X_DB_STATEMENT_TIMEOUT:
222
271
  'add the index this statement needs to the entity (indexes: [...]), then: x db gen "add index"',
223
272
  X_DB_LOCK_TIMEOUT:
224
- `psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'"` +
225
- ' # 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',
226
275
  X_DB_POOL_EXHAUSTED:
227
276
  'set DATABASE_POOL_MAX below max_connections / replicas (per-role default: POOL_PROFILES), ' +
228
277
  'or cut the replica count',
@@ -253,6 +302,24 @@ export const driverError = (statement: string, sourceError: unknown): DbError =>
253
302
  // `X_DB_UNAVAILABLE` said "set DATABASE_URL" to a developer whose database was fine.
254
303
  const code = sqlStateCode(sourceError) ?? 'X_DB_STATEMENT_FAILED';
255
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
+ }
256
323
  return new DbError({
257
324
  code,
258
325
  // The server's SQLSTATE and message FIRST, the statement after: a cause is rendered on one
@@ -265,9 +332,11 @@ export const driverError = (statement: string, sourceError: unknown): DbError =>
265
332
  // ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
266
333
  // `{constraint}` back into the fix line an author is meant to paste.
267
334
  fix:
268
- code === 'X_DB_STATEMENT_FAILED'
269
- ? `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`
270
- : 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),
271
340
  meta: {
272
341
  sqlState: state,
273
342
  ...(constraint === undefined ? {} : { constraint }),
@@ -317,9 +386,11 @@ export const poolAcquireTimeout = (waitedMs: number, max: number): DbError =>
317
386
  export const poolMaxInvalid = (received: string): DbError =>
318
387
  new DbError({
319
388
  code: 'X_ENV_MISSING',
320
- 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`,
321
392
  fix: 'DATABASE_POOL_MAX=20 # a whole number of connections per process, or unset it',
322
- meta: { received },
393
+ meta: { received: describeValue(received) },
323
394
  });
324
395
 
325
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
+ }
@@ -0,0 +1,157 @@
1
+ // The durable failure-bundle store: `x_failures`, written OUTSIDE the request's transaction (which
2
+ // a 5xx rolled back), read by `x repro` and purged on a window the app chooses. Bundles only:
3
+ // counts per fingerprint are `x_error_groups`' (one table per fact).
4
+
5
+ import type { FailureBundle, FailureStore } from '@ultimat3/core';
6
+ import { finiteCount, isJsonObject } from '@ultimat3/core';
7
+ import type { DbClient } from './client';
8
+ import { db } from './client';
9
+ import { outsideTransaction } from './transaction';
10
+
11
+ export const FAILURES_TABLE = 'x_failures';
12
+
13
+ /**
14
+ * Applied by the boot (`FRAMEWORK_SCHEMA`), never by an app migration, whether or not capture is
15
+ * on — enabling it must not be the deploy that finds the table missing. `bundle` is the whole
16
+ * `FailureBundle`; the columns beside it are the lookups `x repro` makes.
17
+ */
18
+ export const SQL_FAILURES_TABLE = `
19
+ create table if not exists x_failures (
20
+ id uuid primary key,
21
+ fingerprint text not null,
22
+ code text not null,
23
+ at timestamptz not null,
24
+ request_id text,
25
+ trace_id text,
26
+ build text,
27
+ primitive text not null,
28
+ name text not null,
29
+ bundle jsonb not null
30
+ );
31
+
32
+ create index if not exists x_failures_fingerprint_idx on x_failures (fingerprint, at desc);
33
+ create index if not exists x_failures_request_idx on x_failures (request_id);
34
+ create index if not exists x_failures_trace_idx on x_failures (trace_id);
35
+ create index if not exists x_failures_at_idx on x_failures (at);
36
+ `;
37
+
38
+ /** The most bundles one `list` answers. */
39
+ export const MAX_FAILURE_LIST = 200;
40
+
41
+ export interface FailureSummary {
42
+ readonly id: string;
43
+ readonly fingerprint: string;
44
+ readonly code: string;
45
+ readonly at: string;
46
+ readonly requestId: string | null;
47
+ readonly name: string;
48
+ readonly build: string | null;
49
+ }
50
+
51
+ export interface PostgresFailureStore extends FailureStore {
52
+ /** By bundle id, request id or trace id — newest first. */
53
+ find(id: string): Promise<FailureBundle | undefined>;
54
+ /** The newest bundle of a group. */
55
+ latest(fingerprint: string): Promise<FailureBundle | undefined>;
56
+ list(options?: {
57
+ readonly code?: string;
58
+ readonly limit?: number;
59
+ }): Promise<readonly FailureSummary[]>;
60
+ purge(beforeMs: number): Promise<number>;
61
+ }
62
+
63
+ interface BundleRow {
64
+ readonly bundle: unknown;
65
+ }
66
+
67
+ const bundleOf = (rows: readonly BundleRow[]): FailureBundle | undefined => {
68
+ const raw = rows[0]?.bundle;
69
+ const parsed: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
70
+ // Written by `save` below from a `FailureBundle`; a row that is not an object is not one.
71
+ return isJsonObject(parsed) ? (parsed as unknown as FailureBundle) : undefined;
72
+ };
73
+
74
+ const iso = (value: unknown): string =>
75
+ value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString();
76
+
77
+ export function postgresFailureStore(
78
+ options: { readonly client?: DbClient } = {},
79
+ ): PostgresFailureStore {
80
+ const client = (): DbClient => options.client ?? db();
81
+ // Never inside the caller's transaction: the request that failed rolled it back.
82
+ const send = <T>(text: string, values: readonly unknown[]): Promise<readonly T[]> =>
83
+ outsideTransaction(() => client().query<T>({ text, values: [...values] }));
84
+ return {
85
+ async save(bundle) {
86
+ await send(
87
+ `insert into ${FAILURES_TABLE} (id, fingerprint, code, at, request_id, trace_id, build, primitive, name, bundle) values ($1, $2, $3, $4::timestamptz, $5, $6, $7, $8, $9, $10::text::jsonb) on conflict (id) do nothing`,
88
+ [
89
+ bundle.id,
90
+ bundle.fingerprint,
91
+ bundle.code,
92
+ new Date(bundle.at).toISOString(),
93
+ bundle.requestId,
94
+ bundle.traceId,
95
+ bundle.build,
96
+ bundle.primitive,
97
+ bundle.name,
98
+ JSON.stringify(bundle),
99
+ ],
100
+ );
101
+ },
102
+ async find(id) {
103
+ return bundleOf(
104
+ await send<BundleRow>(
105
+ `select bundle from ${FAILURES_TABLE} where id::text = $1 or request_id = $1 or trace_id = $1 order by at desc limit 1`,
106
+ [id],
107
+ ),
108
+ );
109
+ },
110
+ async latest(fingerprint) {
111
+ return bundleOf(
112
+ await send<BundleRow>(
113
+ `select bundle from ${FAILURES_TABLE} where fingerprint = $1 order by at desc limit 1`,
114
+ [fingerprint],
115
+ ),
116
+ );
117
+ },
118
+ async list(query = {}) {
119
+ const limit = Math.min(
120
+ finiteCount('postgresFailureStore.list', 'limit', query.limit ?? 50, 1),
121
+ MAX_FAILURE_LIST,
122
+ );
123
+ const rows = await send<Record<string, unknown>>(
124
+ `select id::text as id, fingerprint, code, at, request_id, name, build from ${FAILURES_TABLE}${query.code === undefined ? '' : ' where code = $2'} order by at desc limit $1`,
125
+ query.code === undefined ? [limit] : [limit, query.code],
126
+ );
127
+ return rows.map((row) => ({
128
+ id: String(row['id']),
129
+ fingerprint: String(row['fingerprint']),
130
+ code: String(row['code']),
131
+ at: iso(row['at']),
132
+ requestId: typeof row['request_id'] === 'string' ? row['request_id'] : null,
133
+ name: String(row['name']),
134
+ build: typeof row['build'] === 'string' ? row['build'] : null,
135
+ }));
136
+ },
137
+ async purge(beforeMs) {
138
+ const rows = await send<{ removed: number | string }>(
139
+ `with gone as (delete from ${FAILURES_TABLE} where at < $1::timestamptz returning 1) select count(*)::int as removed from gone`,
140
+ [new Date(beforeMs).toISOString()],
141
+ );
142
+ return Number(rows[0]?.removed ?? 0);
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * A `purge()` target (`@ultimat3/jobs`, structural): bundles older than `keepMs`, measured on the
149
+ * JOB's clock. No default window: how long user input may be kept is the app's decision.
150
+ */
151
+ export function failurePurgeTarget(
152
+ store: Pick<PostgresFailureStore, 'purge'>,
153
+ options: { readonly keepMs: number },
154
+ ): { readonly name: string; purgeExpired(nowMs: number): Promise<number> } {
155
+ const keepMs = finiteCount('failurePurgeTarget', 'keepMs', options.keepMs, 1);
156
+ return { name: FAILURES_TABLE, purgeExpired: (nowMs) => store.purge(nowMs - keepMs) };
157
+ }