@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.
- package/CLAUDE.md +78 -116
- package/README.md +199 -16
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +157 -0
- package/src/concurrent-plan.ts +294 -0
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +21 -35
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +75 -0
- package/src/errors.ts +80 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +99 -0
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +128 -89
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +136 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +47 -0
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +67 -69
- package/src/migration-errors.ts +48 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +192 -0
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- 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
|
-
|
|
16
|
-
missingForeignKey,
|
|
17
|
-
missingIndex,
|
|
18
|
-
missingTable,
|
|
14
|
+
notValidConstraint,
|
|
19
15
|
unexpectedColumn,
|
|
20
16
|
unexpectedTable,
|
|
21
17
|
unknownSchema,
|
|
22
18
|
} from './drift-findings';
|
|
23
|
-
import {
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
236
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/entity-shape.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
' # end the
|
|
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
|
-
|
|
269
|
-
?
|
|
270
|
-
:
|
|
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
|
-
|
|
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
|
/**
|
package/src/expected-loop.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Single responsibility: the one way to declare
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
+
}
|