@ultimat3/db 1.2.0 → 3.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/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 { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
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 Set(expected.columns.map((column) => column.name));
74
- const liveColumns = new Set(live.columns.map((column) => column.name));
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
- if (!liveColumns.has(column.name)) differences.push(missingColumn(live.name, column.name));
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
 
@@ -110,25 +294,73 @@ export function driftError(difference: DriftDifference): DbError {
110
294
  });
111
295
  }
112
296
 
113
- /** Throws the first difference. `x verify` calls this; `x db drift --json` reads the report. */
297
+ /**
298
+ * Throws the first difference. The one caller is the release phase — `runRole` in `@ultimat3/cli`
299
+ * under `ROLE=migrate`, where the exit code is the only channel a container has. `x db migrate`
300
+ * and `x db reset` hold the same report and render every difference as a finding instead
301
+ * (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
302
+ * which never reaches this function. There is no `x db drift` command.
303
+ */
114
304
  export function assertNoDrift(report: DriftReport): void {
115
305
  const first = report.differences[0];
116
306
  if (first !== undefined) throw driftError(first);
117
307
  }
118
308
 
119
309
  /**
120
- * The schema migrations claim. Each generated migration carries the snapshot it leaves behind,
121
- * so the newest applied one with a snapshot is the expectation no SQL is re-parsed.
310
+ * The schema the migration files themselves declare, ledger or no ledger, or `undefined` when
311
+ * they do not declare one. Each generated migration carries the snapshot it leaves behind, so the
312
+ * **newest** migration's snapshot is the claim — no SQL is re-parsed.
313
+ *
314
+ * The newest one, never the newest one that happens to have a snapshot: a later migration without
315
+ * a sidecar has changed the schema in ways nothing wrote down, so an earlier snapshot is not a
316
+ * partial answer but a wrong one. `0001` records `posts`, `0002` adds a column by hand, and
317
+ * reaching back to `0001` reports the column the database correctly holds as `unexpected-column`
318
+ * — drift against a schema that is exactly right, with `x db gen "add …"` as the fix for a
319
+ * migration that already exists.
320
+ *
321
+ * An empty list has nothing to declare and is `{ tables: [] }`, which is a real answer: an app
322
+ * with no migration yet owes the database no table.
323
+ *
324
+ * This is what `x db gen` diffs the app's entities against, and why generating a migration needs
325
+ * no database: the previous migration already wrote down what it left behind.
326
+ */
327
+ export function declaredSchema(migrations: readonly Migration[]): SchemaDescription | undefined {
328
+ const ordered = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1));
329
+ const newest = ordered[ordered.length - 1];
330
+ if (newest === undefined) return { tables: [] };
331
+ return newest.snapshot;
332
+ }
333
+
334
+ /**
335
+ * The schema migrations claim to have *applied* — `declaredSchema` over the ledger's own subset,
336
+ * never a second reading of the same snapshots. Generation asks "what have we written down" and
337
+ * drift asks "what does this database owe us"; two answers, one implementation, so a snapshot can
338
+ * never mean one thing to `x db gen` and another to `x verify`.
122
339
  */
123
340
  export function expectedSchema(
124
341
  migrations: readonly Migration[],
125
342
  ledger: readonly LedgerRow[],
126
- ): SchemaDescription {
343
+ ): SchemaDescription | undefined {
127
344
  const applied = new Set(ledger.map((row) => row.id));
128
- const snapshots = [...migrations]
129
- .filter((migration) => applied.has(migration.id) && migration.snapshot !== undefined)
130
- .sort((a, b) => (a.id < b.id ? -1 : 1));
131
- return snapshots[snapshots.length - 1]?.snapshot ?? { tables: [] };
345
+ return declaredSchema(migrations.filter((migration) => applied.has(migration.id)));
346
+ }
347
+
348
+ /**
349
+ * Framework bookkeeping is not app schema. The ledger, the job queue's tables, the outbox and
350
+ * every `@ultimat3/auth` table are created by `create table if not exists` at boot — no migration
351
+ * declares them and no snapshot carries them, so each one reads as `unexpected-table` against a
352
+ * schema that is in fact correct. The `x_` prefix is the convention every framework table already
353
+ * follows, so a table a future package adds needs no second list here.
354
+ *
355
+ * `introspect()` keeps its own narrower default (`x_migrations` alone) on purpose: the admin
356
+ * dashboard's schema view and the MCP `schema.describe` tool legitimately show `x_users`. Only
357
+ * drift wants the whole namespace gone, so only drift declares it.
358
+ */
359
+ export const FRAMEWORK_TABLE_PREFIX = 'x_';
360
+
361
+ /** The live schema minus framework bookkeeping — what a migration snapshot can be compared to. */
362
+ export function appTables(live: SchemaDescription): SchemaDescription {
363
+ return { tables: live.tables.filter((t) => !t.name.startsWith(FRAMEWORK_TABLE_PREFIX)) };
132
364
  }
133
365
 
134
366
  export interface DriftOptions {
@@ -137,12 +369,29 @@ export interface DriftOptions {
137
369
  readonly schema?: string | undefined;
138
370
  }
139
371
 
372
+ /**
373
+ * **The post-migrate verification**: the live database against the ledger it just wrote. This is
374
+ * the one drift question that needs a database, so it is asked where one is open — `runMigrations`
375
+ * in `@ultimat3/cli`, which is `x db migrate`, `x db reset` and `ROLE=migrate` alike.
376
+ *
377
+ * The other drift question — "the entity source was edited and no migration recorded it" — needs
378
+ * no database and is `x verify`'s `drift` step (`checkSourceDrift`, `@ultimat3/cli`). Two
379
+ * conditions, two detectors, one `X_DB_DRIFT`; a check that opened a database in CI could not run
380
+ * at all, and one that read files could not see a column added by hand.
381
+ */
140
382
  export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
141
383
  const client = options.client ?? baseClient();
142
384
  const ledger = await readLedger(client);
385
+ const expected = expectedSchema(options.migrations, ledger);
386
+ // Unknowable, not clean: the newest applied migration wrote no snapshot, so there is nothing to
387
+ // compare the catalog to. Reported as its own difference rather than answered with a stale
388
+ // snapshot's verdict, because a wrong `ok: false` sends an author to fix a schema that is right
389
+ // and a wrong `ok: true` is the failure this check exists to prevent.
390
+ if (expected === undefined)
391
+ return { ok: false, differences: [unknownSchema(options.migrations)] };
143
392
  const live = await introspect({
144
393
  client,
145
394
  ...(options.schema === undefined ? {} : { schema: options.schema }),
146
395
  });
147
- return diffSchema(live, expectedSchema(options.migrations, ledger));
396
+ return diffSchema(appTables(live), expected);
148
397
  }
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,37 @@ 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
- /** `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. Never titled here, never registered here. */
22
- export const DB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
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
+ * `X_INVARIANT` is core's own "the generic code, for checks that have no dedicated code yet"
37
+ * (`assert()` in `core/src/assert.ts`), borrowed the same way `@ultimat3/money`'s `roundRatio`
38
+ * borrows it: an argument a caller built wrong is not a fact about the ledger or the schema, so
39
+ * none of the `X_MIGRATION_*` codes above describes one.
40
+ */
41
+ export const DB_BORROWED_ERROR_CODES = [
42
+ 'X_NOT_IMPLEMENTED',
43
+ 'X_ENV_MISSING',
44
+ 'X_INVARIANT',
45
+ ] as const;
23
46
 
24
47
  /** Every code db can throw: the ones it owns plus the ones it borrows. */
25
48
  export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
@@ -29,12 +52,20 @@ export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
29
52
 
30
53
  export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
31
54
  X_DB_UNAVAILABLE: 'cannot reach the database',
55
+ X_DB_UNIQUE_VIOLATION: 'a unique constraint rejected the row',
56
+ X_DB_FOREIGN_KEY_VIOLATION: 'a foreign key constraint rejected the row',
57
+ X_DB_SERIALIZATION_FAILURE: 'the transaction lost a serialization race',
58
+ X_DB_STATEMENT_TIMEOUT: 'the statement ran past its statement_timeout',
59
+ X_DB_LOCK_TIMEOUT: 'the statement waited past its lock_timeout',
60
+ X_DB_POOL_EXHAUSTED: 'no connection was available',
32
61
  X_DB_DRIFT: 'schema differs from migrations',
33
62
  X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
63
+ X_MIGRATE_CONCURRENT: 'another migrator holds the migration lock',
34
64
  X_MIGRATION_IRREVERSIBLE: 'this migration cannot be reversed without data loss',
65
+ X_MIGRATION_DESTRUCTIVE: 'this migration destroys data and does not say so',
66
+ X_MIGRATION_SNAPSHOT_MISSING: 'the newest migration records no schema snapshot',
35
67
  X_SQL_UNSAFE: 'SQL was built by string interpolation',
36
68
  X_BRANCH_EXISTS: 'that branch database already exists',
37
- X_READONLY_VIOLATION: 'a mutating statement reached a read-only client',
38
69
  };
39
70
 
40
71
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -81,6 +112,137 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
81
112
  sourceError,
82
113
  });
83
114
 
115
+ /**
116
+ * One `fix:` per classified SQLSTATE, written once. Every one names a command that exists or an
117
+ * edit the reader can make — a `23505` telling an operator the database is unreachable is the
118
+ * failure this table exists to end.
119
+ *
120
+ * `X_DB_UNIQUE_VIOLATION`'s and `X_DB_FOREIGN_KEY_VIOLATION`'s take the constraint the server
121
+ * named, so the fix points at the one index or key that refused the row rather than at the idea
122
+ * of one; `driverError` substitutes the placeholder when the driver reported none.
123
+ */
124
+ const SQLSTATE_FIXES: Readonly<Record<DbSqlStateCode, string>> = Object.freeze({
125
+ X_DB_UNIQUE_VIOLATION:
126
+ 'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
127
+ 'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
128
+ X_DB_FOREIGN_KEY_VIOLATION:
129
+ 'insert the row {constraint} points at first, in the same withTransaction(...) — ' +
130
+ 'or drop the write, because the parent it names is gone',
131
+ X_DB_SERIALIZATION_FAILURE:
132
+ 'withTransaction(fn, { retry: 3 }) # fn re-runs from the top, so it must be idempotent',
133
+ X_DB_STATEMENT_TIMEOUT:
134
+ 'add the index this statement needs to the entity (indexes: [...]), then: x db gen "add index"',
135
+ X_DB_LOCK_TIMEOUT:
136
+ `psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'"` +
137
+ ' # end the blocker, then re-run the statement',
138
+ X_DB_POOL_EXHAUSTED:
139
+ 'set DATABASE_POOL_MAX below max_connections / replicas (per-role default: POOL_PROFILES), ' +
140
+ 'or cut the replica count',
141
+ });
142
+
143
+ /** Substituted into a fix when the driver named no constraint — `{constraint}`'s stand-in. */
144
+ const UNNAMED_CONSTRAINT = 'the constraint named in cause';
145
+
146
+ /**
147
+ * Every driver failure, typed by what the server actually said. The SQLSTATE has always been on
148
+ * the error — `isLedgerMissing` proved the read worked — and nothing exposed it, so a `23505`
149
+ * unique violation, a `40001` serialization failure and a `57014` timeout all reached the caller
150
+ * as `X_DB_UNAVAILABLE`, whose fix is "set DATABASE_URL to a reachable Postgres url". Two clicks
151
+ * racing a signup paged on-call for an outage that never happened.
152
+ *
153
+ * `X_DB_UNAVAILABLE` stays the answer for everything the table does not classify, including every
154
+ * failure that never reached a server: that code's meaning is unchanged, its fix is finally only
155
+ * given where it is true, and a new SQLSTATE arrives as a new row here rather than as a new
156
+ * `catch` at a call site.
157
+ */
158
+ export const driverError = (detail: string, sourceError: unknown): DbError => {
159
+ const code = sqlStateCode(sourceError);
160
+ if (code === undefined) return dbUnavailable(detail, sourceError);
161
+ const state = sqlState(sourceError);
162
+ const constraint = stringField(sourceError, 'constraint');
163
+ return new DbError({
164
+ code,
165
+ cause: `${detail}: ${renderThrowable(sourceError)} [SQLSTATE ${state ?? '?????'}]`,
166
+ // A FUNCTION as the replacement, never the string: `String.replace` expands `$&`, `` $` ``,
167
+ // `$'` and `$$` inside a replacement literal, and a constraint name is the server's, not
168
+ // ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
169
+ // `{constraint}` back into the fix line an author is meant to paste.
170
+ fix: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
171
+ meta: {
172
+ sqlState: state,
173
+ ...(constraint === undefined ? {} : { constraint }),
174
+ },
175
+ sourceError,
176
+ });
177
+ };
178
+
179
+ /**
180
+ * The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and
181
+ * deliberately the same code: to a caller both mean "there was no connection for this unit of
182
+ * work", and a second code would split one runbook in two. Queueing forever instead turns
183
+ * exhaustion into a hang — `/readyz` joins the queue, the kubelet kills the pod, and the next pod
184
+ * inherits the same saturated database.
185
+ */
186
+ export const poolAcquireTimeout = (waitedMs: number, max: number): DbError =>
187
+ new DbError({
188
+ code: 'X_DB_POOL_EXHAUSTED',
189
+ cause: `no connection came free within ${waitedMs}ms; every one of the pool's ${max} is in use`,
190
+ fix: SQLSTATE_FIXES.X_DB_POOL_EXHAUSTED,
191
+ meta: { waitedMs, max },
192
+ });
193
+
194
+ /**
195
+ * `DATABASE_POOL_MAX` is the one pool knob an operator can reach without a rebuild, so a typo in it
196
+ * must refuse at boot rather than silently fall back to the role default — a fleet that ignored the
197
+ * value it was given is the failure the variable exists to prevent.
198
+ */
199
+ export const poolMaxInvalid = (received: string): DbError =>
200
+ new DbError({
201
+ code: 'X_ENV_MISSING',
202
+ cause: `DATABASE_POOL_MAX is ${JSON.stringify(received)}, which is not a positive integer`,
203
+ fix: 'DATABASE_POOL_MAX=20 # a whole number of connections per process, or unset it',
204
+ meta: { received },
205
+ });
206
+
207
+ /**
208
+ * `withTransaction(fn, { retry: n })` re-ran `fn` from the top `n` times and lost the race every
209
+ * time. The last driver error is kept as `sourceError` so the SQLSTATE survives, and the cause
210
+ * names the count because "it failed again" and "it failed 4 times in a row" are different
211
+ * problems: the second one is contention the application has to reduce, not a retry to add.
212
+ */
213
+ export const serializationExhausted = (attempts: number, sourceError: unknown): DbError =>
214
+ new DbError({
215
+ code: 'X_DB_SERIALIZATION_FAILURE',
216
+ cause:
217
+ `the transaction lost its serialization race on all ${attempts} attempts: ` +
218
+ renderThrowable(sourceError),
219
+ fix:
220
+ 'raise the retry budget — withTransaction(fn, { retry: 8 }) — or cut the contention: ' +
221
+ "narrow what the transaction reads, or drop to isolation: 'repeatable read'",
222
+ meta: { attempts },
223
+ sourceError,
224
+ });
225
+
226
+ /**
227
+ * The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
228
+ * no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
229
+ * left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
230
+ * failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
231
+ * exit code.
232
+ */
233
+ export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
234
+ new DbError({
235
+ code: 'X_MIGRATE_CONCURRENT',
236
+ cause:
237
+ `another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
238
+ 'so this migrator refused rather than block a deploy forever',
239
+ fix:
240
+ 'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
241
+ "join pg_locks using (pid) where locktype = 'advisory'\"" +
242
+ ' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
243
+ meta: { lockKey, waitedMs },
244
+ });
245
+
84
246
  /** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
85
247
  export const dbDrift = (tableName: string, columnName: string): DbError =>
86
248
  new DbError({
@@ -96,6 +258,79 @@ export const migrationConflict = (cause: string, fix: string): DbError =>
96
258
  export const migrationIrreversible = (cause: string, fix: string): DbError =>
97
259
  new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
98
260
 
261
+ /**
262
+ * A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
263
+ * negative count counts from the END: `steps: -1` selected every applied migration except the
264
+ * newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
265
+ * Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
266
+ * as a different one is the failure a validated argument exists to prevent.
267
+ */
268
+ export const rollbackStepsInvalid = (received: number): DbError =>
269
+ new DbError({
270
+ code: 'X_INVARIANT',
271
+ cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
272
+ fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
273
+ meta: { steps: received },
274
+ });
275
+
276
+ /**
277
+ * `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
278
+ * every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
279
+ * rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
280
+ * keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
281
+ */
282
+ const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
283
+
284
+ /**
285
+ * `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
286
+ * and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
287
+ * the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
288
+ */
289
+ const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
290
+
291
+ /**
292
+ * The sidecar every generated migration writes is what the *next* generation diffs against, so a
293
+ * newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
294
+ * schema, which would generate `create table` for every table the database already holds.
295
+ */
296
+ export const migrationSnapshotMissing = (id: string, file: string): DbError =>
297
+ new DbError({
298
+ code: 'X_MIGRATION_SNAPSHOT_MISSING',
299
+ cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
300
+ // Two remedies, both commands, in the order they are safe to try. "restore from version
301
+ // control" alone was neither: on a scaffolded app the sidecar was never written, so there is
302
+ // nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
303
+ // the two errors pointed at each other and an app's first migration had no way out.
304
+ // `x db gen` is named only *after* the files it would trip over are gone.
305
+ fix:
306
+ `git checkout -- ${file} # or, if it was never written: ` +
307
+ `rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
308
+ meta: { id, file },
309
+ });
310
+
311
+ /**
312
+ * One error per file, never one per statement: the marker declares the whole migration, so a
313
+ * second finding would repeat an instruction the first already gave. `file` is app-relative and
314
+ * arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
315
+ *
316
+ * Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
317
+ * a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
318
+ * destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
319
+ */
320
+ export const migrationDestructive = (
321
+ file: string,
322
+ first: DestructiveStatement,
323
+ more = 0,
324
+ ): DbError =>
325
+ new DbError({
326
+ code: 'X_MIGRATION_DESTRUCTIVE',
327
+ cause:
328
+ `${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
329
+ `${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
330
+ fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
331
+ meta: { file, kind: first.kind, statements: more + 1 },
332
+ });
333
+
99
334
  export const sqlUnsafe = (received: string, position: number): DbError =>
100
335
  new DbError({
101
336
  code: 'X_SQL_UNSAFE',
@@ -114,6 +349,22 @@ export const identifierUnsafe = (name: string): DbError =>
114
349
  meta: { name },
115
350
  });
116
351
 
352
+ /**
353
+ * More than one command in a text that gets **spliced** — into `DECLARE … CURSOR FOR`, or sent
354
+ * whole on a driver that degrades to the simple protocol. `X_SQL_UNSAFE` rather than a validation
355
+ * code for the same reason `branchNameInvalid` uses it: a second command riding an interpolated
356
+ * statement is an injection, not a typo. Only the first is bounded by the guards `readOnlyQuery`
357
+ * just installed, so `SET LOCAL statement_timeout` was undone by the second while `guards` still
358
+ * reported `timeout:5000ms` — a defeated layer reported as an engaged one.
359
+ */
360
+ export const multipleStatements = (statement: string, count: number): DbError =>
361
+ new DbError({
362
+ code: 'X_SQL_UNSAFE',
363
+ cause: `a read-only query must be ONE statement; this text holds ${count}: ${statement}`,
364
+ fix: 'await readOnlyQuery(first); await readOnlyQuery(second) # one statement per call',
365
+ meta: { count },
366
+ });
367
+
117
368
  export const branchExists = (branch: string): DbError =>
118
369
  new DbError({
119
370
  code: 'X_BRANCH_EXISTS',
@@ -134,14 +385,6 @@ export const branchNameInvalid = (branch: string): DbError =>
134
385
  meta: { branch },
135
386
  });
136
387
 
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
388
  export const dbNotImplemented = (feature: string, fix: string): DbError =>
146
389
  new DbError({
147
390
  code: 'X_NOT_IMPLEMENTED',