@ultimat3/db 1.1.0 → 2.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 +580 -0
- package/README.md +147 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +10 -4
- package/src/client.ts +241 -25
- package/src/destructive.ts +126 -0
- package/src/drift.ts +256 -13
- package/src/errors.ts +232 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +37 -11
- package/src/introspect.ts +34 -8
- package/src/migrate.ts +272 -63
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
package/src/drift.ts
CHANGED
|
@@ -5,14 +5,26 @@
|
|
|
5
5
|
|
|
6
6
|
import { baseClient, type DbClient } from './client';
|
|
7
7
|
import { DbError } from './errors';
|
|
8
|
-
import {
|
|
8
|
+
import { foreignKeyTarget } from './foreign-key';
|
|
9
|
+
import {
|
|
10
|
+
type ForeignKeyDescription,
|
|
11
|
+
findTable,
|
|
12
|
+
introspect,
|
|
13
|
+
type SchemaDescription,
|
|
14
|
+
type TableDescription,
|
|
15
|
+
} from './introspect';
|
|
9
16
|
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
10
17
|
|
|
11
18
|
export type DriftKind =
|
|
12
19
|
| 'unexpected-column'
|
|
13
20
|
| 'missing-column'
|
|
21
|
+
| 'changed-column'
|
|
14
22
|
| 'unexpected-table'
|
|
15
|
-
| 'missing-table'
|
|
23
|
+
| 'missing-table'
|
|
24
|
+
| 'unknown-schema'
|
|
25
|
+
| 'missing-index'
|
|
26
|
+
| 'changed-index'
|
|
27
|
+
| 'missing-foreign-key';
|
|
16
28
|
|
|
17
29
|
export interface DriftDifference {
|
|
18
30
|
readonly kind: DriftKind;
|
|
@@ -48,6 +60,35 @@ function missingColumn(table: string, column: string): DriftDifference {
|
|
|
48
60
|
};
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The column exists on both sides and one of them lets it be `NULL`.
|
|
65
|
+
*
|
|
66
|
+
* This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT
|
|
67
|
+
* NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the
|
|
68
|
+
* strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing
|
|
69
|
+
* a human has to remember. Nobody did, and `compareTable` compared columns by name and by type
|
|
70
|
+
* while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever
|
|
71
|
+
* against an entity schema that said otherwise, with `ok: true` on every check. The first
|
|
72
|
+
* `undefined` write then lands as `NULL` and crashes three services away from the migration.
|
|
73
|
+
*
|
|
74
|
+
* `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
|
|
75
|
+
* `set not null`, so naming it would send a reader to a command that generates an empty migration.
|
|
76
|
+
*/
|
|
77
|
+
function changedColumn(table: string, column: string, liveNullable: boolean): DriftDifference {
|
|
78
|
+
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
79
|
+
return {
|
|
80
|
+
kind: 'changed-column',
|
|
81
|
+
table,
|
|
82
|
+
column,
|
|
83
|
+
cause: liveNullable
|
|
84
|
+
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
85
|
+
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
86
|
+
fix:
|
|
87
|
+
`alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
|
|
88
|
+
(liveNullable ? ' — backfill the existing NULLs first' : ''),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
function unexpectedTable(table: string): DriftDifference {
|
|
52
93
|
return {
|
|
53
94
|
kind: 'unexpected-table',
|
|
@@ -68,17 +109,160 @@ function missingTable(table: string): DriftDifference {
|
|
|
68
109
|
};
|
|
69
110
|
}
|
|
70
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Not a difference between two schemas but the absence of one to compare against — reported
|
|
114
|
+
* through the same channel so it reaches an operator, since a check that quietly answered "clean"
|
|
115
|
+
* because it had nothing to check is the one failure mode drift detection cannot have.
|
|
116
|
+
*/
|
|
117
|
+
function unknownSchema(migrations: readonly Migration[]): DriftDifference {
|
|
118
|
+
const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
|
|
119
|
+
return {
|
|
120
|
+
kind: 'unknown-schema',
|
|
121
|
+
table: '',
|
|
122
|
+
column: null,
|
|
123
|
+
cause:
|
|
124
|
+
`migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
|
|
125
|
+
'cannot be established',
|
|
126
|
+
// The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
|
|
127
|
+
// the same condition. It used to lead with `x db gen`, which raises that error and whose own
|
|
128
|
+
// fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
|
|
129
|
+
// pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
|
|
130
|
+
fix:
|
|
131
|
+
`git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
|
|
132
|
+
`delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function missingIndex(table: string, index: string): DriftDifference {
|
|
137
|
+
return {
|
|
138
|
+
kind: 'missing-index',
|
|
139
|
+
table,
|
|
140
|
+
column: null,
|
|
141
|
+
cause: `table "${table}" is missing index "${index}" that migrations declare`,
|
|
142
|
+
fix: 'x db migrate',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function changedIndex(table: string, index: string, detail: string): DriftDifference {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'changed-index',
|
|
149
|
+
table,
|
|
150
|
+
column: null,
|
|
151
|
+
cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
|
|
152
|
+
fix: 'x db migrate',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'missing-foreign-key',
|
|
159
|
+
table,
|
|
160
|
+
column: null,
|
|
161
|
+
cause:
|
|
162
|
+
`table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
|
|
163
|
+
`"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
|
|
164
|
+
fix: 'x db migrate',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Indexes migrations declare, against the ones the catalog holds — by column list and by
|
|
170
|
+
* uniqueness, which is what caught a composite index rebuilt with its columns the other way round
|
|
171
|
+
* while `ok: true` said the schema agreed.
|
|
172
|
+
*
|
|
173
|
+
* Only the declared side is judged. A live index no snapshot names is **not** drift: Postgres
|
|
174
|
+
* creates one for every primary key and every unique constraint, no migration declares those, and
|
|
175
|
+
* an index a DBA added is a planner decision rather than a schema divergence — reporting them
|
|
176
|
+
* would be eight findings against a correct database, which is how a drift check earns being
|
|
177
|
+
* ignored (`appTables` exists for the same reason).
|
|
178
|
+
*
|
|
179
|
+
* The predicate and the direction are deliberately **not** compared: the catalog returns its own
|
|
180
|
+
* rewriting of an expression (`(deleted_at IS NULL)`) and a snapshot holds the author's spelling,
|
|
181
|
+
* so a text comparison reports drift on two identical indexes. `x db gen` compares them instead,
|
|
182
|
+
* where both sides are generated — see `redefineIndex` in `generate.ts`. Named in
|
|
183
|
+
* `wiki/Known-Gaps.md`.
|
|
184
|
+
*/
|
|
185
|
+
function compareIndexes(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
186
|
+
const differences: DriftDifference[] = [];
|
|
187
|
+
const present = new Map(live.indexes.map((index) => [index.name, index]));
|
|
188
|
+
for (const index of expected.indexes) {
|
|
189
|
+
const counterpart = present.get(index.name);
|
|
190
|
+
if (counterpart === undefined) {
|
|
191
|
+
differences.push(missingIndex(live.name, index.name));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (counterpart.columns.join(',') !== index.columns.join(',')) {
|
|
195
|
+
differences.push(
|
|
196
|
+
changedIndex(live.name, index.name, `covers (${counterpart.columns.join(', ')})`),
|
|
197
|
+
);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (counterpart.unique !== index.unique) {
|
|
201
|
+
differences.push(
|
|
202
|
+
changedIndex(live.name, index.name, counterpart.unique ? 'is unique' : 'is not unique'),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return differences;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Foreign keys migrations declare, against the ones the catalog holds — matched on **where the key
|
|
211
|
+
* points**, never on its name. `snapshotOf` names one the way Postgres names an inline `references`
|
|
212
|
+
* clause (`posts_org_id_fkey`), a hand-written migration may have said `constraint fk_posts_org`,
|
|
213
|
+
* and a constraint that points the same columns at the same table is the same constraint whatever
|
|
214
|
+
* it is called; comparing the name would report drift on a database that is exactly right.
|
|
215
|
+
*
|
|
216
|
+
* `onDelete` is not compared either: the catalog spells it as a single character (`a`, `c`, `r`)
|
|
217
|
+
* and no generated clause declares one, so a snapshot has nothing truthful to hold there. Only the
|
|
218
|
+
* declared side is judged, for the reason `compareIndexes` gives. Named in `wiki/Known-Gaps.md`.
|
|
219
|
+
*/
|
|
220
|
+
function compareForeignKeys(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
221
|
+
// The same identity `x db gen` diffs on (`foreign-key.ts`): a generator and a detector that
|
|
222
|
+
// disagreed about whether two keys are the same key is drift on a correct database.
|
|
223
|
+
const present = new Set(live.foreignKeys.map(foreignKeyTarget));
|
|
224
|
+
return expected.foreignKeys
|
|
225
|
+
.filter((key) => !present.has(foreignKeyTarget(key)))
|
|
226
|
+
.map((key) => missingForeignKey(live.name, key));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
|
|
231
|
+
* adds the constraint with the key. Both sides are therefore read through the union of the two
|
|
232
|
+
* primary keys, or a table whose snapshot spells its key column nullable reports a difference
|
|
233
|
+
* against a database that is exactly right and cannot be anything else. The union, not one side:
|
|
234
|
+
* a key present on only one of them is a difference the *key* comparison owns, and reporting it
|
|
235
|
+
* again as a nullability change would be one fault with two findings.
|
|
236
|
+
*/
|
|
237
|
+
function keyColumnsOf(live: TableDescription, expected: TableDescription): ReadonlySet<string> {
|
|
238
|
+
return new Set([...live.primaryKey, ...expected.primaryKey]);
|
|
239
|
+
}
|
|
240
|
+
|
|
71
241
|
function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
72
242
|
const differences: DriftDifference[] = [];
|
|
73
|
-
const expectedColumns = new
|
|
74
|
-
const liveColumns = new
|
|
243
|
+
const expectedColumns = new Map(expected.columns.map((column) => [column.name, column]));
|
|
244
|
+
const liveColumns = new Map(live.columns.map((column) => [column.name, column]));
|
|
245
|
+
const keyColumns = keyColumnsOf(live, expected);
|
|
75
246
|
for (const column of live.columns) {
|
|
76
247
|
if (expectedColumns.has(column.name)) continue;
|
|
77
248
|
differences.push(unexpectedColumn(live.name, column.name));
|
|
78
249
|
}
|
|
79
250
|
for (const column of expected.columns) {
|
|
80
|
-
|
|
251
|
+
const counterpart = liveColumns.get(column.name);
|
|
252
|
+
if (counterpart === undefined) {
|
|
253
|
+
differences.push(missingColumn(live.name, column.name));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// Nullability, not the type: the catalog and a snapshot spell types differently often enough
|
|
257
|
+
// that comparing them here would report drift on a correct database, and `x db gen`'s
|
|
258
|
+
// `retypeColumn` already owns that question where both sides are generated.
|
|
259
|
+
if (keyColumns.has(column.name)) continue;
|
|
260
|
+
if (column.nullable !== counterpart.nullable) {
|
|
261
|
+
differences.push(changedColumn(live.name, column.name, counterpart.nullable));
|
|
262
|
+
}
|
|
81
263
|
}
|
|
264
|
+
differences.push(...compareIndexes(live, expected));
|
|
265
|
+
differences.push(...compareForeignKeys(live, expected));
|
|
82
266
|
return differences;
|
|
83
267
|
}
|
|
84
268
|
|
|
@@ -117,18 +301,60 @@ export function assertNoDrift(report: DriftReport): void {
|
|
|
117
301
|
}
|
|
118
302
|
|
|
119
303
|
/**
|
|
120
|
-
* The schema
|
|
121
|
-
*
|
|
304
|
+
* The schema the migration files themselves declare, ledger or no ledger, or `undefined` when
|
|
305
|
+
* they do not declare one. Each generated migration carries the snapshot it leaves behind, so the
|
|
306
|
+
* **newest** migration's snapshot is the claim — no SQL is re-parsed.
|
|
307
|
+
*
|
|
308
|
+
* The newest one, never the newest one that happens to have a snapshot: a later migration without
|
|
309
|
+
* a sidecar has changed the schema in ways nothing wrote down, so an earlier snapshot is not a
|
|
310
|
+
* partial answer but a wrong one. `0001` records `posts`, `0002` adds a column by hand, and
|
|
311
|
+
* reaching back to `0001` reports the column the database correctly holds as `unexpected-column`
|
|
312
|
+
* — drift against a schema that is exactly right, with `x db gen "add …"` as the fix for a
|
|
313
|
+
* migration that already exists.
|
|
314
|
+
*
|
|
315
|
+
* An empty list has nothing to declare and is `{ tables: [] }`, which is a real answer: an app
|
|
316
|
+
* with no migration yet owes the database no table.
|
|
317
|
+
*
|
|
318
|
+
* This is what `x db gen` diffs the app's entities against, and why generating a migration needs
|
|
319
|
+
* no database: the previous migration already wrote down what it left behind.
|
|
320
|
+
*/
|
|
321
|
+
export function declaredSchema(migrations: readonly Migration[]): SchemaDescription | undefined {
|
|
322
|
+
const ordered = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
323
|
+
const newest = ordered[ordered.length - 1];
|
|
324
|
+
if (newest === undefined) return { tables: [] };
|
|
325
|
+
return newest.snapshot;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The schema migrations claim to have *applied* — `declaredSchema` over the ledger's own subset,
|
|
330
|
+
* never a second reading of the same snapshots. Generation asks "what have we written down" and
|
|
331
|
+
* drift asks "what does this database owe us"; two answers, one implementation, so a snapshot can
|
|
332
|
+
* never mean one thing to `x db gen` and another to `x verify`.
|
|
122
333
|
*/
|
|
123
334
|
export function expectedSchema(
|
|
124
335
|
migrations: readonly Migration[],
|
|
125
336
|
ledger: readonly LedgerRow[],
|
|
126
|
-
): SchemaDescription {
|
|
337
|
+
): SchemaDescription | undefined {
|
|
127
338
|
const applied = new Set(ledger.map((row) => row.id));
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
339
|
+
return declaredSchema(migrations.filter((migration) => applied.has(migration.id)));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Framework bookkeeping is not app schema. The ledger, the job queue's tables, the outbox and
|
|
344
|
+
* every `@ultimat3/auth` table are created by `create table if not exists` at boot — no migration
|
|
345
|
+
* declares them and no snapshot carries them, so each one reads as `unexpected-table` against a
|
|
346
|
+
* schema that is in fact correct. The `x_` prefix is the convention every framework table already
|
|
347
|
+
* follows, so a table a future package adds needs no second list here.
|
|
348
|
+
*
|
|
349
|
+
* `introspect()` keeps its own narrower default (`x_migrations` alone) on purpose: the admin
|
|
350
|
+
* dashboard's schema view and the MCP `schema.describe` tool legitimately show `x_users`. Only
|
|
351
|
+
* drift wants the whole namespace gone, so only drift declares it.
|
|
352
|
+
*/
|
|
353
|
+
export const FRAMEWORK_TABLE_PREFIX = 'x_';
|
|
354
|
+
|
|
355
|
+
/** The live schema minus framework bookkeeping — what a migration snapshot can be compared to. */
|
|
356
|
+
export function appTables(live: SchemaDescription): SchemaDescription {
|
|
357
|
+
return { tables: live.tables.filter((t) => !t.name.startsWith(FRAMEWORK_TABLE_PREFIX)) };
|
|
132
358
|
}
|
|
133
359
|
|
|
134
360
|
export interface DriftOptions {
|
|
@@ -137,12 +363,29 @@ export interface DriftOptions {
|
|
|
137
363
|
readonly schema?: string | undefined;
|
|
138
364
|
}
|
|
139
365
|
|
|
366
|
+
/**
|
|
367
|
+
* **The post-migrate verification**: the live database against the ledger it just wrote. This is
|
|
368
|
+
* the one drift question that needs a database, so it is asked where one is open — `runMigrations`
|
|
369
|
+
* in `@ultimat3/cli`, which is `x db migrate`, `x db reset` and `ROLE=migrate` alike.
|
|
370
|
+
*
|
|
371
|
+
* The other drift question — "the entity source was edited and no migration recorded it" — needs
|
|
372
|
+
* no database and is `x verify`'s `drift` step (`checkSourceDrift`, `@ultimat3/cli`). Two
|
|
373
|
+
* conditions, two detectors, one `X_DB_DRIFT`; a check that opened a database in CI could not run
|
|
374
|
+
* at all, and one that read files could not see a column added by hand.
|
|
375
|
+
*/
|
|
140
376
|
export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
|
|
141
377
|
const client = options.client ?? baseClient();
|
|
142
378
|
const ledger = await readLedger(client);
|
|
379
|
+
const expected = expectedSchema(options.migrations, ledger);
|
|
380
|
+
// Unknowable, not clean: the newest applied migration wrote no snapshot, so there is nothing to
|
|
381
|
+
// compare the catalog to. Reported as its own difference rather than answered with a stale
|
|
382
|
+
// snapshot's verdict, because a wrong `ok: false` sends an author to fix a schema that is right
|
|
383
|
+
// and a wrong `ok: true` is the failure this check exists to prevent.
|
|
384
|
+
if (expected === undefined)
|
|
385
|
+
return { ok: false, differences: [unknownSchema(options.migrations)] };
|
|
143
386
|
const live = await introspect({
|
|
144
387
|
client,
|
|
145
388
|
...(options.schema === undefined ? {} : { schema: options.schema }),
|
|
146
389
|
});
|
|
147
|
-
return diffSchema(live,
|
|
390
|
+
return diffSchema(appTables(live), expected);
|
|
148
391
|
}
|
package/src/errors.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
|
|
3
3
|
// pinned by the framework contract, so change its strings only with the contract.
|
|
4
4
|
|
|
5
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { registerErrorCodes, renderThrowable, stringField, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
|
|
7
|
+
import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Codes this package declares and owns. `X_DB_DRIFT` is db's: it is a statement about migrations
|
|
@@ -10,16 +12,28 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
|
10
12
|
*/
|
|
11
13
|
export const DB_OWNED_ERROR_CODES = [
|
|
12
14
|
'X_DB_UNAVAILABLE',
|
|
15
|
+
'X_DB_UNIQUE_VIOLATION',
|
|
16
|
+
'X_DB_FOREIGN_KEY_VIOLATION',
|
|
17
|
+
'X_DB_SERIALIZATION_FAILURE',
|
|
18
|
+
'X_DB_STATEMENT_TIMEOUT',
|
|
19
|
+
'X_DB_LOCK_TIMEOUT',
|
|
20
|
+
'X_DB_POOL_EXHAUSTED',
|
|
13
21
|
'X_DB_DRIFT',
|
|
14
22
|
'X_MIGRATION_CONFLICT',
|
|
15
23
|
'X_MIGRATION_IRREVERSIBLE',
|
|
24
|
+
'X_MIGRATION_DESTRUCTIVE',
|
|
25
|
+
'X_MIGRATION_SNAPSHOT_MISSING',
|
|
26
|
+
'X_MIGRATE_CONCURRENT',
|
|
16
27
|
'X_SQL_UNSAFE',
|
|
17
28
|
'X_BRANCH_EXISTS',
|
|
18
|
-
'X_READONLY_VIOLATION',
|
|
19
29
|
] as const;
|
|
20
30
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `@ultimat3/core`'s. Never titled here, never registered here. `X_ENV_MISSING` is core's word for
|
|
33
|
+
* "a variable this process was given is missing or invalid", and `DATABASE_POOL_MAX` is one — a
|
|
34
|
+
* db-local code for it would be a second answer to a question core already answers.
|
|
35
|
+
*/
|
|
36
|
+
export const DB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ENV_MISSING'] as const;
|
|
23
37
|
|
|
24
38
|
/** Every code db can throw: the ones it owns plus the ones it borrows. */
|
|
25
39
|
export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
|
|
@@ -29,12 +43,20 @@ export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
|
|
|
29
43
|
|
|
30
44
|
export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
31
45
|
X_DB_UNAVAILABLE: 'cannot reach the database',
|
|
46
|
+
X_DB_UNIQUE_VIOLATION: 'a unique constraint rejected the row',
|
|
47
|
+
X_DB_FOREIGN_KEY_VIOLATION: 'a foreign key constraint rejected the row',
|
|
48
|
+
X_DB_SERIALIZATION_FAILURE: 'the transaction lost a serialization race',
|
|
49
|
+
X_DB_STATEMENT_TIMEOUT: 'the statement ran past its statement_timeout',
|
|
50
|
+
X_DB_LOCK_TIMEOUT: 'the statement waited past its lock_timeout',
|
|
51
|
+
X_DB_POOL_EXHAUSTED: 'no connection was available',
|
|
32
52
|
X_DB_DRIFT: 'schema differs from migrations',
|
|
33
53
|
X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
|
|
54
|
+
X_MIGRATE_CONCURRENT: 'another migrator holds the migration lock',
|
|
34
55
|
X_MIGRATION_IRREVERSIBLE: 'this migration cannot be reversed without data loss',
|
|
56
|
+
X_MIGRATION_DESTRUCTIVE: 'this migration destroys data and does not say so',
|
|
57
|
+
X_MIGRATION_SNAPSHOT_MISSING: 'the newest migration records no schema snapshot',
|
|
35
58
|
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
36
59
|
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
37
|
-
X_READONLY_VIOLATION: 'a mutating statement reached a read-only client',
|
|
38
60
|
};
|
|
39
61
|
|
|
40
62
|
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
|
@@ -81,6 +103,137 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
|
81
103
|
sourceError,
|
|
82
104
|
});
|
|
83
105
|
|
|
106
|
+
/**
|
|
107
|
+
* One `fix:` per classified SQLSTATE, written once. Every one names a command that exists or an
|
|
108
|
+
* edit the reader can make — a `23505` telling an operator the database is unreachable is the
|
|
109
|
+
* failure this table exists to end.
|
|
110
|
+
*
|
|
111
|
+
* `X_DB_UNIQUE_VIOLATION`'s and `X_DB_FOREIGN_KEY_VIOLATION`'s take the constraint the server
|
|
112
|
+
* named, so the fix points at the one index or key that refused the row rather than at the idea
|
|
113
|
+
* of one; `driverError` substitutes the placeholder when the driver reported none.
|
|
114
|
+
*/
|
|
115
|
+
const SQLSTATE_FIXES: Readonly<Record<DbSqlStateCode, string>> = Object.freeze({
|
|
116
|
+
X_DB_UNIQUE_VIOLATION:
|
|
117
|
+
'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
|
|
118
|
+
'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
|
|
119
|
+
X_DB_FOREIGN_KEY_VIOLATION:
|
|
120
|
+
'insert the row {constraint} points at first, in the same withTransaction(...) — ' +
|
|
121
|
+
'or drop the write, because the parent it names is gone',
|
|
122
|
+
X_DB_SERIALIZATION_FAILURE:
|
|
123
|
+
'withTransaction(fn, { retry: 3 }) # fn re-runs from the top, so it must be idempotent',
|
|
124
|
+
X_DB_STATEMENT_TIMEOUT:
|
|
125
|
+
'add the index this statement needs to the entity (indexes: [...]), then: x db gen "add index"',
|
|
126
|
+
X_DB_LOCK_TIMEOUT:
|
|
127
|
+
`psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'"` +
|
|
128
|
+
' # end the blocker, then re-run the statement',
|
|
129
|
+
X_DB_POOL_EXHAUSTED:
|
|
130
|
+
'set DATABASE_POOL_MAX below max_connections / replicas (per-role default: POOL_PROFILES), ' +
|
|
131
|
+
'or cut the replica count',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** Substituted into a fix when the driver named no constraint — `{constraint}`'s stand-in. */
|
|
135
|
+
const UNNAMED_CONSTRAINT = 'the constraint named in cause';
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every driver failure, typed by what the server actually said. The SQLSTATE has always been on
|
|
139
|
+
* the error — `isLedgerMissing` proved the read worked — and nothing exposed it, so a `23505`
|
|
140
|
+
* unique violation, a `40001` serialization failure and a `57014` timeout all reached the caller
|
|
141
|
+
* as `X_DB_UNAVAILABLE`, whose fix is "set DATABASE_URL to a reachable Postgres url". Two clicks
|
|
142
|
+
* racing a signup paged on-call for an outage that never happened.
|
|
143
|
+
*
|
|
144
|
+
* `X_DB_UNAVAILABLE` stays the answer for everything the table does not classify, including every
|
|
145
|
+
* failure that never reached a server: that code's meaning is unchanged, its fix is finally only
|
|
146
|
+
* given where it is true, and a new SQLSTATE arrives as a new row here rather than as a new
|
|
147
|
+
* `catch` at a call site.
|
|
148
|
+
*/
|
|
149
|
+
export const driverError = (detail: string, sourceError: unknown): DbError => {
|
|
150
|
+
const code = sqlStateCode(sourceError);
|
|
151
|
+
if (code === undefined) return dbUnavailable(detail, sourceError);
|
|
152
|
+
const state = sqlState(sourceError);
|
|
153
|
+
const constraint = stringField(sourceError, 'constraint');
|
|
154
|
+
return new DbError({
|
|
155
|
+
code,
|
|
156
|
+
cause: `${detail}: ${renderThrowable(sourceError)} [SQLSTATE ${state ?? '?????'}]`,
|
|
157
|
+
// A FUNCTION as the replacement, never the string: `String.replace` expands `$&`, `` $` ``,
|
|
158
|
+
// `$'` and `$$` inside a replacement literal, and a constraint name is the server's, not
|
|
159
|
+
// ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
|
|
160
|
+
// `{constraint}` back into the fix line an author is meant to paste.
|
|
161
|
+
fix: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
|
|
162
|
+
meta: {
|
|
163
|
+
sqlState: state,
|
|
164
|
+
...(constraint === undefined ? {} : { constraint }),
|
|
165
|
+
},
|
|
166
|
+
sourceError,
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and
|
|
172
|
+
* deliberately the same code: to a caller both mean "there was no connection for this unit of
|
|
173
|
+
* work", and a second code would split one runbook in two. Queueing forever instead turns
|
|
174
|
+
* exhaustion into a hang — `/readyz` joins the queue, the kubelet kills the pod, and the next pod
|
|
175
|
+
* inherits the same saturated database.
|
|
176
|
+
*/
|
|
177
|
+
export const poolAcquireTimeout = (waitedMs: number, max: number): DbError =>
|
|
178
|
+
new DbError({
|
|
179
|
+
code: 'X_DB_POOL_EXHAUSTED',
|
|
180
|
+
cause: `no connection came free within ${waitedMs}ms; every one of the pool's ${max} is in use`,
|
|
181
|
+
fix: SQLSTATE_FIXES.X_DB_POOL_EXHAUSTED,
|
|
182
|
+
meta: { waitedMs, max },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `DATABASE_POOL_MAX` is the one pool knob an operator can reach without a rebuild, so a typo in it
|
|
187
|
+
* must refuse at boot rather than silently fall back to the role default — a fleet that ignored the
|
|
188
|
+
* value it was given is the failure the variable exists to prevent.
|
|
189
|
+
*/
|
|
190
|
+
export const poolMaxInvalid = (received: string): DbError =>
|
|
191
|
+
new DbError({
|
|
192
|
+
code: 'X_ENV_MISSING',
|
|
193
|
+
cause: `DATABASE_POOL_MAX is ${JSON.stringify(received)}, which is not a positive integer`,
|
|
194
|
+
fix: 'DATABASE_POOL_MAX=20 # a whole number of connections per process, or unset it',
|
|
195
|
+
meta: { received },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `withTransaction(fn, { retry: n })` re-ran `fn` from the top `n` times and lost the race every
|
|
200
|
+
* time. The last driver error is kept as `sourceError` so the SQLSTATE survives, and the cause
|
|
201
|
+
* names the count because "it failed again" and "it failed 4 times in a row" are different
|
|
202
|
+
* problems: the second one is contention the application has to reduce, not a retry to add.
|
|
203
|
+
*/
|
|
204
|
+
export const serializationExhausted = (attempts: number, sourceError: unknown): DbError =>
|
|
205
|
+
new DbError({
|
|
206
|
+
code: 'X_DB_SERIALIZATION_FAILURE',
|
|
207
|
+
cause:
|
|
208
|
+
`the transaction lost its serialization race on all ${attempts} attempts: ` +
|
|
209
|
+
renderThrowable(sourceError),
|
|
210
|
+
fix:
|
|
211
|
+
'raise the retry budget — withTransaction(fn, { retry: 8 }) — or cut the contention: ' +
|
|
212
|
+
"narrow what the transaction reads, or drop to isolation: 'repeatable read'",
|
|
213
|
+
meta: { attempts },
|
|
214
|
+
sourceError,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
|
|
219
|
+
* no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
|
|
220
|
+
* left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
|
|
221
|
+
* failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
|
|
222
|
+
* exit code.
|
|
223
|
+
*/
|
|
224
|
+
export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
|
|
225
|
+
new DbError({
|
|
226
|
+
code: 'X_MIGRATE_CONCURRENT',
|
|
227
|
+
cause:
|
|
228
|
+
`another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
|
|
229
|
+
'so this migrator refused rather than block a deploy forever',
|
|
230
|
+
fix:
|
|
231
|
+
'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
|
|
232
|
+
"join pg_locks using (pid) where locktype = 'advisory'\"" +
|
|
233
|
+
' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
|
|
234
|
+
meta: { lockKey, waitedMs },
|
|
235
|
+
});
|
|
236
|
+
|
|
84
237
|
/** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
|
|
85
238
|
export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
86
239
|
new DbError({
|
|
@@ -96,6 +249,64 @@ export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
|
96
249
|
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
97
250
|
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
98
251
|
|
|
252
|
+
/**
|
|
253
|
+
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
254
|
+
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
|
255
|
+
* rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
|
|
256
|
+
* keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
|
|
257
|
+
*/
|
|
258
|
+
const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
|
|
262
|
+
* and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
|
|
263
|
+
* the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
|
|
264
|
+
*/
|
|
265
|
+
const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The sidecar every generated migration writes is what the *next* generation diffs against, so a
|
|
269
|
+
* newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
|
|
270
|
+
* schema, which would generate `create table` for every table the database already holds.
|
|
271
|
+
*/
|
|
272
|
+
export const migrationSnapshotMissing = (id: string, file: string): DbError =>
|
|
273
|
+
new DbError({
|
|
274
|
+
code: 'X_MIGRATION_SNAPSHOT_MISSING',
|
|
275
|
+
cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
|
|
276
|
+
// Two remedies, both commands, in the order they are safe to try. "restore from version
|
|
277
|
+
// control" alone was neither: on a scaffolded app the sidecar was never written, so there is
|
|
278
|
+
// nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
|
|
279
|
+
// the two errors pointed at each other and an app's first migration had no way out.
|
|
280
|
+
// `x db gen` is named only *after* the files it would trip over are gone.
|
|
281
|
+
fix:
|
|
282
|
+
`git checkout -- ${file} # or, if it was never written: ` +
|
|
283
|
+
`rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
|
|
284
|
+
meta: { id, file },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* One error per file, never one per statement: the marker declares the whole migration, so a
|
|
289
|
+
* second finding would repeat an instruction the first already gave. `file` is app-relative and
|
|
290
|
+
* arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
|
|
291
|
+
*
|
|
292
|
+
* Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
|
|
293
|
+
* a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
|
|
294
|
+
* destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
|
|
295
|
+
*/
|
|
296
|
+
export const migrationDestructive = (
|
|
297
|
+
file: string,
|
|
298
|
+
first: DestructiveStatement,
|
|
299
|
+
more = 0,
|
|
300
|
+
): DbError =>
|
|
301
|
+
new DbError({
|
|
302
|
+
code: 'X_MIGRATION_DESTRUCTIVE',
|
|
303
|
+
cause:
|
|
304
|
+
`${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
|
|
305
|
+
`${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
|
|
306
|
+
fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
|
|
307
|
+
meta: { file, kind: first.kind, statements: more + 1 },
|
|
308
|
+
});
|
|
309
|
+
|
|
99
310
|
export const sqlUnsafe = (received: string, position: number): DbError =>
|
|
100
311
|
new DbError({
|
|
101
312
|
code: 'X_SQL_UNSAFE',
|
|
@@ -114,6 +325,22 @@ export const identifierUnsafe = (name: string): DbError =>
|
|
|
114
325
|
meta: { name },
|
|
115
326
|
});
|
|
116
327
|
|
|
328
|
+
/**
|
|
329
|
+
* More than one command in a text that gets **spliced** — into `DECLARE … CURSOR FOR`, or sent
|
|
330
|
+
* whole on a driver that degrades to the simple protocol. `X_SQL_UNSAFE` rather than a validation
|
|
331
|
+
* code for the same reason `branchNameInvalid` uses it: a second command riding an interpolated
|
|
332
|
+
* statement is an injection, not a typo. Only the first is bounded by the guards `readOnlyQuery`
|
|
333
|
+
* just installed, so `SET LOCAL statement_timeout` was undone by the second while `guards` still
|
|
334
|
+
* reported `timeout:5000ms` — a defeated layer reported as an engaged one.
|
|
335
|
+
*/
|
|
336
|
+
export const multipleStatements = (statement: string, count: number): DbError =>
|
|
337
|
+
new DbError({
|
|
338
|
+
code: 'X_SQL_UNSAFE',
|
|
339
|
+
cause: `a read-only query must be ONE statement; this text holds ${count}: ${statement}`,
|
|
340
|
+
fix: 'await readOnlyQuery(first); await readOnlyQuery(second) # one statement per call',
|
|
341
|
+
meta: { count },
|
|
342
|
+
});
|
|
343
|
+
|
|
117
344
|
export const branchExists = (branch: string): DbError =>
|
|
118
345
|
new DbError({
|
|
119
346
|
code: 'X_BRANCH_EXISTS',
|
|
@@ -134,14 +361,6 @@ export const branchNameInvalid = (branch: string): DbError =>
|
|
|
134
361
|
meta: { branch },
|
|
135
362
|
});
|
|
136
363
|
|
|
137
|
-
export const readonlyViolation = (statement: string, keyword: string): DbError =>
|
|
138
|
-
new DbError({
|
|
139
|
-
code: 'X_READONLY_VIOLATION',
|
|
140
|
-
cause: `a read-only client received a ${keyword.toUpperCase()} statement: ${statement}`,
|
|
141
|
-
fix: 'use db() instead of readOnly(db()), or rewrite the statement as a SELECT',
|
|
142
|
-
meta: { keyword },
|
|
143
|
-
});
|
|
144
|
-
|
|
145
364
|
export const dbNotImplemented = (feature: string, fix: string): DbError =>
|
|
146
365
|
new DbError({
|
|
147
366
|
code: 'X_NOT_IMPLEMENTED',
|