@ultimat3/db 23.0.0 → 24.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.
@@ -10,9 +10,9 @@ import { DbError } from './errors';
10
10
  import { shellInertIdentifier } from './sql';
11
11
 
12
12
  /**
13
- * The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync; that
14
- * one screens the column through the same `@ultimat3/db` export, so the two lines are the same
15
- * text on both sides of the tier seam.
13
+ * The contract's pinned wording, and the ONE definition of it: `@ultimat3/entity` carried a second
14
+ * `dbDrift()` held in step by a comment and a test, deleted `As of 2026-10-02`. Drift is this
15
+ * package's to raise.
16
16
  *
17
17
  * The column name is the CATALOG's, so it is data: whoever can add a column picks the text that
18
18
  * lands here, and `x db gen "add C"` puts it inside SHELL DOUBLE QUOTES, where `$(…)` and a
@@ -1,19 +1,13 @@
1
- // Single responsibility: what a schema difference is CALLED and what its `fix:` line says — one
2
- // constructor per `DriftKind`, and nothing that compares anything. Split out of `drift.ts` at the
3
- // 500-line ceiling, along the seam that file already drew: comparison decides *whether* two
4
- // schemas disagree, and this decides how the disagreement reads.
5
- //
6
- // The rendered `X_DB_DRIFT` output is byte-for-byte pinned by the framework contract and
7
- // duplicated in `@ultimat3/entity` — do not reword a `cause` without changing both.
8
- //
9
- // Two rules run through every one of them. A `fix:` is a command the reader can RUN: `x db
10
- // migrate` where the migration has not been applied, and the statement itself where it has, since
11
- // re-running the migrator applies nothing a ledger row already claims. And a difference names the
12
- // declared side's own spelling, never the catalog's, because the catalog's is Postgres' rewriting.
1
+ // Single responsibility: what a schema difference is CALLED and what its `fix:` says — one
2
+ // constructor per `DriftKind`, and nothing that compares anything (`drift.ts` decides whether two
3
+ // schemas disagree). A `fix:` is ONE command a shell runs, and a difference names the declared
4
+ // side's spelling, never the catalog's — which is Postgres' rewriting.
13
5
 
14
- import { onDeleteRule, rebuildForeignKey } from './foreign-key';
6
+ import { psqlCommand } from './dependent-view';
7
+ import { addForeignKey, dropForeignKey, onDeleteRule } from './foreign-key';
15
8
  import type { CheckDescription, ForeignKeyDescription } from './introspect';
16
9
  import type { Migration } from './migrate';
10
+ import { addPrimaryKey, dropPrimaryKey } from './primary-key';
17
11
  import { shellInertIdentifier } from './sql';
18
12
 
19
13
  export type DriftKind =
@@ -25,6 +19,7 @@ export type DriftKind =
25
19
  | 'unknown-schema'
26
20
  | 'missing-index'
27
21
  | 'changed-index'
22
+ | 'changed-primary-key'
28
23
  | 'missing-check'
29
24
  | 'missing-foreign-key'
30
25
  | 'changed-foreign-key'
@@ -45,6 +40,66 @@ export interface DriftReport {
45
40
  readonly differences: readonly DriftDifference[];
46
41
  }
47
42
 
43
+ const RE_CHECK = 'then x db migrate, which re-checks';
44
+
45
+ /** `set not null` is refused by the server while a row still holds NULL, and says so here. */
46
+ const NULLS_FIRST = `refused while a row holds NULL there, so backfill those first; ${RE_CHECK}`;
47
+
48
+ const CARRIES =
49
+ 'a name in this difference carries a backtick, a dollar sign, a quote, a backslash or whitespace';
50
+
51
+ const UNSPELLABLE = `${CARRIES}, so no statement here can spell it`;
52
+
53
+ /**
54
+ * `x db migrate` is the fix where a migration has not been applied. Where it has, re-running the
55
+ * migrator applies nothing a ledger row already claims, so the fix is the statement itself —
56
+ * a repair made against THIS database, as one line a shell runs: the statement is `psql`'s
57
+ * argument (`psqlCommand`), never bare DDL beside a `#` — `#` is not a comment to Postgres and
58
+ * `alter` is not a program to a shell, so neither reader could run that line (axiom 4). Against
59
+ * this database and never "in a new migration": drift means this database left the migrations,
60
+ * and a migration would re-apply the repair to every database that is already right.
61
+ */
62
+ const repair = (path: string, statements: string, note = RE_CHECK): string =>
63
+ `${psqlCommand(`${path}${statements}`)} # ${note}`;
64
+
65
+ /**
66
+ * The same repair when no statement can be written — a name the screen refuses. Still a command
67
+ * that runs: a psql session, with what to do in it as the comment. No name rides in it, hostile
68
+ * or not; the `cause` holds them, and nobody pastes a cause.
69
+ */
70
+ export const byHand = (steps: string, why = UNSPELLABLE): string =>
71
+ `psql "$DATABASE_URL" # ${steps}, \\q, ${RE_CHECK} — ${why}`;
72
+
73
+ /** The schema Postgres resolves an unqualified name in when a session sets nothing. */
74
+ const DEFAULT_SCHEMA = 'public';
75
+
76
+ /**
77
+ * What puts a statement in the schema its table was READ from: nothing for the default one — the
78
+ * text every app has seen — and `set search_path` in the same psql word for any other, so the
79
+ * table, and every table the statement references, resolves there. A `psql "$DATABASE_URL"`
80
+ * session starts on its own search_path: unqualified, `alter table "posts"` for a table in
81
+ * `tenant_a` fails, or lands on a same-named table in `public`. `null` for a schema no statement
82
+ * can spell.
83
+ */
84
+ const pathTo = (schema: string): string | null => {
85
+ if (schema === DEFAULT_SCHEMA) return '';
86
+ const name = shellInertIdentifier(schema);
87
+ return name === null ? null : `set search_path = ${name}; `;
88
+ };
89
+
90
+ /** A table as a psql PATTERN or a statement outside `pathTo`: qualified unless the default schema. */
91
+ const qualified = (schema: string, table: string): string | null => {
92
+ const name = shellInertIdentifier(table);
93
+ if (name === null) return null;
94
+ if (schema === DEFAULT_SCHEMA) return name;
95
+ const space = shellInertIdentifier(schema);
96
+ return space === null ? null : `${space}.${name}`;
97
+ };
98
+
99
+ /** Every name inert in a shell AND writable as an identifier — the one screen, asked of each. */
100
+ const spellable = (names: readonly string[]): boolean =>
101
+ names.every((name) => shellInertIdentifier(name) !== null);
102
+
48
103
  /**
49
104
  * The one `fix:` here whose second layer no quoting closes. `x db gen "add C"` puts the column
50
105
  * inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick substitute before `x` is reached at all
@@ -93,13 +148,16 @@ export function missingColumn(table: string, column: string): DriftDifference {
93
148
  *
94
149
  * `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
95
150
  * `set not null`, so naming it would send a reader to a command that generates an empty migration.
151
+ * The fix is the statement, run against this database (`repair`).
96
152
  */
97
153
  export function changedColumn(
154
+ schema: string,
98
155
  table: string,
99
156
  column: string,
100
157
  liveNullable: boolean,
101
158
  ): DriftDifference {
102
159
  const clause = liveNullable ? 'set not null' : 'drop not null';
160
+ const path = pathTo(schema);
103
161
  const relation = shellInertIdentifier(table);
104
162
  const attribute = shellInertIdentifier(column);
105
163
  return {
@@ -109,16 +167,15 @@ export function changedColumn(
109
167
  cause: liveNullable
110
168
  ? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
111
169
  : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
112
- // Both identifiers are the catalog's, so both go through the one screen. A refusal names the
113
- // column as the thing it could not spell, which is what tells this line apart from
114
- // `missingCheck`'s refusal in a report that carries both.
170
+ // Both identifiers are the catalog's, so both go through the one screen.
115
171
  fix:
116
- relation === null || attribute === null
117
- ? `${clause} on the column named in this difference, in a new migration, then ` +
118
- 'x db migrate — its table or column name carries a backtick, a dollar sign, a quote, ' +
119
- 'a backslash or whitespace, so no statement here can spell it'
120
- : `alter table ${relation} alter column ${attribute} ${clause}; # in a new migration` +
121
- (liveNullable ? ' — backfill the existing NULLs first' : ''),
172
+ path === null || relation === null || attribute === null
173
+ ? byHand(`alter column … ${clause} on the column this difference names`)
174
+ : repair(
175
+ path,
176
+ `alter table ${relation} alter column ${attribute} ${clause};`,
177
+ liveNullable ? NULLS_FIRST : RE_CHECK,
178
+ ),
122
179
  };
123
180
  }
124
181
 
@@ -134,22 +191,32 @@ export function changedColumn(
134
191
  * the relation is already there, and `x db migrate` then accepts a table its own SQL creates), or
135
192
  * nothing owns it and it should not be in this schema. No migration PATH is named: where an app
136
193
  * keeps its migrations is the CLI's fact, not this package's.
194
+ *
195
+ * Two repairs and one line, so the line leads with the command neither repair can skip — `\\d` on
196
+ * the table, through `psqlCommand`, which is what keeps a `'` in the name inside its shell word.
137
197
  */
138
- export function unexpectedTable(table: string): DriftDifference {
139
- const name = shellInertIdentifier(table);
198
+ export function unexpectedTable(schema: string, table: string): DriftDifference {
199
+ const name = qualified(schema, table);
200
+ // The comment repeats the name only when it holds no `'`: a shell that does not read `#` as a
201
+ // comment (interactive zsh, by default) would open a quote on one. The command is safe either
202
+ // way — `psqlCommand` escapes it inside its own word.
203
+ const spoken = name === null || name.includes("'") ? 'this table' : name;
140
204
  return {
141
205
  kind: 'unexpected-table',
142
206
  table,
143
207
  column: null,
144
208
  cause: `table "${table}" is not present in any migration`,
209
+ // The command is the harmless one — it SHOWS the table, which either repair needs first — and
210
+ // the two repairs are its comment.
145
211
  fix:
146
212
  name === null
147
- ? 'claim it in a migration with create table if not exists, or drop it by hand — its ' +
148
- 'table name carries a backtick, a dollar sign, a quote, a backslash or whitespace, so ' +
149
- 'no statement here can spell it'
150
- : `put a create table if not exists ${name} (…) statement in a migration — x db migrate ` +
151
- 'then accepts a table its own SQL creates — or, if nothing owns it, run ' +
152
- `drop table ${name}; inside psql "$DATABASE_URL"`,
213
+ ? byHand(
214
+ `inspect the table this difference names with \\d, then claim it in a migration ` +
215
+ 'with create table if not exists or drop it',
216
+ )
217
+ : `${psqlCommand(`\\d ${name}`)} # nothing declares it: put create table if not exists ` +
218
+ `${spoken} (…) in a migration, then x db migrate — or, if nothing owns it, run ` +
219
+ `drop table ${spoken}; here`,
153
220
  };
154
221
  }
155
222
 
@@ -176,7 +243,7 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
176
243
  // id off the file and derives the name from it — so whoever can add a file to the migrations
177
244
  // directory picks what a reader pastes, and `$(…)` and a backtick substitute before `git` or `x`
178
245
  // is reached. The same screen `unexpectedColumn` and `changedColumn` already ran, on the one
179
- // finding in this file that skipped it. Degraded to prose rather than escaped: a glob is not an
246
+ // finding in this file that skipped it. Degraded to a read-only command rather than escaped: a glob is not an
180
247
  // identifier and a migration description is not one either, so neither has a quoted form that
181
248
  // makes a hostile name safe. An EMPTY id is inert by construction and keeps its glob — that is
182
249
  // "no migrations at all", not a name this function refused to spell.
@@ -196,10 +263,10 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
196
263
  fix: spellable
197
264
  ? `git checkout -- "*${id}.snapshot.json" # or, if it was never written: ` +
198
265
  `delete migration "${id}" and rerun x db gen "${name}"`
199
- : 'restore the .snapshot.json committed beside the newest migration, or delete that ' +
200
- 'migration and rerun x db gen with its description — the migration named in this ' +
201
- "difference's cause carries a backtick, a dollar sign, a quote, a backslash or " +
202
- 'whitespace in its file name, so no command here can spell it',
266
+ : 'git status --short -- "*.snapshot.json" # shows the sidecar that is gone: git ' +
267
+ 'checkout it, or delete that migration and rerun x db gen with its description — the ' +
268
+ "migration named in this difference's cause carries a backtick, a dollar sign, a " +
269
+ 'quote, a backslash or whitespace in its file name, so no command here can spell it',
203
270
  };
204
271
  }
205
272
 
@@ -232,12 +299,17 @@ export function changedIndex(table: string, index: string, detail: string): Drif
232
299
  * 'published'::text])))` — so a text comparison reports drift on a correct database forever, and
233
300
  * normalising it is an expression parser competing with the server's. Presence is not text.
234
301
  *
235
- * The `fix` is the statement, not `x db migrate`: the migration that declares this constraint is
236
- * already in the ledger, so re-running the migrator applies nothing. Same reasoning as
237
- * `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling of
238
- * the predicate, which is what makes an executable fix possible at all.
302
+ * The `fix` is the statement (`repair`), not `x db migrate`: the migration that declares this
303
+ * constraint is already in the ledger, so re-running the migrator applies nothing. Same reasoning
304
+ * as `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling
305
+ * of the predicate, which is what makes an executable fix possible at all.
239
306
  */
240
- export function missingCheck(table: string, check: CheckDescription): DriftDifference {
307
+ export function missingCheck(
308
+ schema: string,
309
+ table: string,
310
+ check: CheckDescription,
311
+ ): DriftDifference {
312
+ const path = pathTo(schema);
241
313
  const relation = shellInertIdentifier(table);
242
314
  const constraint = shellInertIdentifier(check.name);
243
315
  return {
@@ -245,23 +317,18 @@ export function missingCheck(table: string, check: CheckDescription): DriftDiffe
245
317
  table,
246
318
  column: null,
247
319
  cause: `table "${table}" is missing check constraint "${check.name}" that migrations declare`,
248
- // The command rides on the same line as the statement, and not only because `check` is a
249
- // banned advice word the `errors` gate demands a command beside: writing the migration is half
250
- // the repair and applying it is the other half, and `changedColumn`'s bare `# in a new
251
- // migration` leaves the second half to be guessed.
252
- //
253
320
  // Both NAMES go through the one screen; the EXPRESSION deliberately does not, and cannot. It
254
321
  // is a predicate, so no screen could accept `status in ('draft', 'published')` and reject a
255
322
  // second statement — and it is the DECLARED side's own text, out of the author's migration,
256
- // where both names are the catalog's and a sidecar's. Narrower than "this line is safe", and
257
- // it is the honest claim.
323
+ // where both names are the catalog's and a sidecar's. What `psqlCommand` does close is the
324
+ // shell layer: the statement is one single-quoted word, so nothing in the predicate expands.
258
325
  fix:
259
- relation === null || constraint === null
260
- ? 'add the constraint named in this difference back in a new migration, then ' +
261
- 'x db migrate — its table or constraint name carries a backtick, a dollar sign, a ' +
262
- 'quote, a backslash or whitespace, so no statement here can spell it'
263
- : `alter table ${relation} add constraint ${constraint} ` +
264
- `check (${check.expression}); # in a new migration, then x db migrate`,
326
+ path === null || relation === null || constraint === null
327
+ ? byHand('add the check constraint this difference names back')
328
+ : repair(
329
+ path,
330
+ `alter table ${relation} add constraint ${constraint} check (${check.expression});`,
331
+ ),
265
332
  };
266
333
  }
267
334
 
@@ -282,26 +349,109 @@ export function missingForeignKey(table: string, key: ForeignKeyDescription): Dr
282
349
  * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
283
350
  * there, and what changed is what happens to the child rows.
284
351
  *
285
- * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
286
- * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
287
- * naming a command would send a reader to one that generates an empty migration. Same reasoning
288
- * as `changedColumn`.
352
+ * The `fix` is the pair (`repair`), not `x db migrate`: a rule cannot be altered in place, `add
353
+ * constraint` alone is `42710` on a name already taken, and no `x db gen` diff emits either
354
+ * statement. Same reasoning as `changedColumn`.
355
+ *
356
+ * `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s, so every name is
357
+ * screened and the two writers are ASKED whether they can write the pair — never a second copy of
358
+ * their rules beside them. `identifier()` refuses a name holding a quote, a space or a backslash
359
+ * and `addForeignKey` refuses an `on delete` rule Postgres does not have: right for DDL this
360
+ * package SENDS, wrong for a `fix:`. `diffSchema` is documented pure and total, so a pair it
361
+ * cannot write is a psql session and a sentence, never a throw.
362
+ *
363
+ * The CAUSE names the constraint the database holds, whatever it is called: a refused name is out
364
+ * of the command, and the cause is where a reader still finds which key this is.
289
365
  */
290
366
  export function changedForeignKey(
367
+ schema: string,
291
368
  table: string,
292
369
  declared: ForeignKeyDescription,
293
370
  held: ForeignKeyDescription,
294
371
  ): DriftDifference {
295
372
  const rule = onDeleteRule(held.onDelete);
373
+ const rebuilt = (): string => {
374
+ const steps =
375
+ 'drop the foreign key this difference names and add it back with the on delete rule ' +
376
+ 'migrations declare';
377
+ const names = [
378
+ table,
379
+ held.name,
380
+ declared.name,
381
+ declared.referencedTable,
382
+ ...declared.columns,
383
+ ...declared.referencedColumns,
384
+ ];
385
+ const path = pathTo(schema);
386
+ if (path === null || !spellable(names)) return byHand(steps);
387
+ try {
388
+ return repair(path, `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`);
389
+ } catch {
390
+ return byHand(steps, 'the rule migrations declare is not one Postgres has');
391
+ }
392
+ };
296
393
  return {
297
394
  kind: 'changed-foreign-key',
298
395
  table,
299
396
  column: null,
300
397
  cause:
301
- `foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
398
+ `foreign key "${held.name}" on "${table}" (${declared.columns.join(', ')}) to ` +
302
399
  `"${declared.referencedTable}" ` +
303
400
  `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
304
401
  'migrations declare',
305
- fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
402
+ fix: rebuilt(),
403
+ };
404
+ }
405
+
406
+ const PRIMARY_KEY_BY_HAND = byHand(
407
+ 'drop the primary key this database holds, add the one migrations declare',
408
+ `${CARRIES} or is too long, so no statement here can spell it`,
409
+ );
410
+
411
+ const keyText = (columns: readonly string[]): string =>
412
+ columns.length === 0 ? 'no primary key' : `primary key (${columns.join(', ')})`;
413
+
414
+ /**
415
+ * The two sides key the table differently — a different column list, a different ORDER, or a key
416
+ * on one side only. Its own kind rather than `changed-index` on `<table>_pkey`: that finding's fix
417
+ * is `x db migrate`, and the migration declaring this key is already in the ledger, so re-running
418
+ * the migrator applies nothing.
419
+ *
420
+ * The fix is ONE command a shell runs — the pair as `psql`'s argument (`repair`).
421
+ *
422
+ * `held` is the constraint the DATABASE holds — the live primary index's name, which is the
423
+ * constraint's — because that is the one a `drop constraint` has to spell. The writers are asked
424
+ * whether they can write each statement and a refusal degrades the whole line to prose, the rule
425
+ * `changedForeignKey` states: every name on the live side is the catalog's.
426
+ */
427
+ export function changedPrimaryKey(
428
+ schema: string,
429
+ table: string,
430
+ live: readonly string[],
431
+ held: string | undefined,
432
+ declared: readonly string[],
433
+ ): DriftDifference {
434
+ const statements = (): string => {
435
+ const names = [table, ...declared, ...(held === undefined ? [] : [held])];
436
+ const path = pathTo(schema);
437
+ if (path === null || !spellable(names)) return PRIMARY_KEY_BY_HAND;
438
+ try {
439
+ const parts: string[] = [];
440
+ if (held !== undefined) parts.push(dropPrimaryKey(table, held, false));
441
+ if (declared.length > 0) parts.push(addPrimaryKey(table, declared));
442
+ return repair(path, parts.join(' '));
443
+ } catch {
444
+ return PRIMARY_KEY_BY_HAND;
445
+ }
446
+ };
447
+ return {
448
+ kind: 'changed-primary-key',
449
+ table,
450
+ column: null,
451
+ cause:
452
+ `table "${table}" has ${keyText(live)}${held === undefined ? '' : ` as constraint "${held}"`}, ` +
453
+ 'and migrations declare ' +
454
+ (declared.length === 0 ? 'none' : `(${declared.join(', ')})`),
455
+ fix: statements(),
306
456
  };
307
457
  }
package/src/drift.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  changedColumn,
10
10
  changedForeignKey,
11
11
  changedIndex,
12
+ changedPrimaryKey,
12
13
  missingCheck,
13
14
  missingColumn,
14
15
  missingForeignKey,
@@ -23,6 +24,7 @@ import { foreignKeyTarget, onDeleteRule } from './foreign-key';
23
24
  import { indexMethodOf } from './index-method';
24
25
  import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
25
26
  import { type LedgerRow, type Migration, readLedger } from './migrate';
27
+ import { sameColumns } from './primary-key';
26
28
 
27
29
  // Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
28
30
  // split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
@@ -145,7 +147,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
145
147
  continue;
146
148
  }
147
149
  if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
148
- differences.push(changedForeignKey(live.name, key, counterpart));
150
+ differences.push(changedForeignKey(live.schema, live.name, key, counterpart));
149
151
  }
150
152
  }
151
153
  return differences;
@@ -175,26 +177,29 @@ function compareChecks(live: TableDescription, expected: TableDescription): Drif
175
177
  const present = new Set(held);
176
178
  return declared
177
179
  .filter((check) => !present.has(check.name))
178
- .map((check) => missingCheck(live.name, check));
180
+ .map((check) => missingCheck(live.schema, live.name, check));
179
181
  }
180
182
 
181
183
  /**
182
- * A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
183
- * adds the constraint with the key. Both sides are therefore read through the union of the two
184
- * primary keys, or a table whose snapshot spells its key column nullable reports a difference
185
- * against a database that is exactly right and cannot be anything else. The union, not one side:
186
- * a key present on only one of them is a difference the *key* comparison owns, and reporting it
187
- * again as a nullability change would be one fault with two findings.
184
+ * The two key lists, compared in ORDER. The comment that used to stand here said a key on one side
185
+ * only "is a difference the key comparison owns" — and no key comparison existed, so a table
186
+ * re-keyed by hand, or one whose key no migration ever produced, read `ok: true`.
188
187
  */
189
- function keyColumnsOf(live: TableDescription, expected: TableDescription): ReadonlySet<string> {
190
- return new Set([...live.primaryKey, ...expected.primaryKey]);
188
+ function comparePrimaryKey(live: TableDescription, expected: TableDescription): DriftDifference[] {
189
+ if (sameColumns(live.primaryKey, expected.primaryKey)) return [];
190
+ const held = live.indexes.find((index) => index.primary)?.name;
191
+ return [changedPrimaryKey(live.schema, live.name, live.primaryKey, held, expected.primaryKey)];
191
192
  }
192
193
 
193
194
  function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
194
195
  const differences: DriftDifference[] = [];
195
196
  const expectedColumns = new Map(expected.columns.map((column) => [column.name, column]));
196
197
  const liveColumns = new Map(live.columns.map((column) => [column.name, column]));
197
- const keyColumns = keyColumnsOf(live, expected);
198
+ // A primary key column is `NOT NULL` in the catalog whether or not anything declared it —
199
+ // Postgres adds the constraint with the key — so a snapshot spelling its key column nullable is
200
+ // not drift. The DECLARED key only: a column the database alone keys stays NOT NULL after the
201
+ // stray constraint is dropped, which is a second fault and reported as one.
202
+ const keyColumns = new Set(expected.primaryKey);
198
203
  for (const column of live.columns) {
199
204
  if (expectedColumns.has(column.name)) continue;
200
205
  differences.push(unexpectedColumn(live.name, column.name));
@@ -210,9 +215,10 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
210
215
  // `retypeColumn` already owns that question where both sides are generated.
211
216
  if (keyColumns.has(column.name)) continue;
212
217
  if (column.nullable !== counterpart.nullable) {
213
- differences.push(changedColumn(live.name, column.name, counterpart.nullable));
218
+ differences.push(changedColumn(live.schema, live.name, column.name, counterpart.nullable));
214
219
  }
215
220
  }
221
+ differences.push(...comparePrimaryKey(live, expected));
216
222
  differences.push(...compareIndexes(live, expected));
217
223
  differences.push(...compareChecks(live, expected));
218
224
  differences.push(...compareForeignKeys(live, expected));
@@ -224,7 +230,7 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
224
230
  const differences: DriftDifference[] = [];
225
231
  for (const table of live.tables) {
226
232
  const counterpart = findTable(expected, table.name);
227
- if (counterpart === undefined) differences.push(unexpectedTable(table.name));
233
+ if (counterpart === undefined) differences.push(unexpectedTable(table.schema, table.name));
228
234
  else differences.push(...compareTable(table, counterpart));
229
235
  }
230
236
  for (const table of expected.tables) {
package/src/errors.ts CHANGED
@@ -38,6 +38,9 @@ export const DB_OWNED_ERROR_CODES = [
38
38
  'X_SQL_UNSAFE',
39
39
  'X_BRANCH_EXISTS',
40
40
  'X_SCHEMA_DUMP_DRIFT',
41
+ 'X_DB_TRANSACTION_ABORTED',
42
+ 'X_DB_COMMIT_UNKNOWN',
43
+ 'X_DB_SIBLING_SCOPE_TIMEOUT',
41
44
  ] as const;
42
45
 
43
46
  /**
@@ -83,6 +86,9 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
83
86
  X_SQL_UNSAFE: 'SQL was built by string interpolation',
84
87
  X_BRANCH_EXISTS: 'that branch database already exists',
85
88
  X_SCHEMA_DUMP_DRIFT: 'the committed schema dump is not what the migrations produce',
89
+ X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
90
+ X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
91
+ X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
86
92
  };
87
93
 
88
94
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -416,10 +422,3 @@ export const isolationLevelInvalid = (received: unknown): DbError =>
416
422
  cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
417
423
  fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
418
424
  });
419
-
420
- export const dbNotImplemented = (feature: string, fix: string): DbError =>
421
- new DbError({
422
- code: 'X_NOT_IMPLEMENTED',
423
- cause: `${feature} is not implemented by this driver`,
424
- fix,
425
- });
@@ -130,37 +130,3 @@ export function unrestorableNote(table: string, constraint: string, gone: string
130
130
  `cannot be restored; ${identifier(gone).text} is gone`
131
131
  );
132
132
  }
133
-
134
- /**
135
- * The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
136
- * `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
137
- *
138
- * It lives here, beside the two writers, because it is the one caller reading values neither of
139
- * them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
140
- * writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
141
- * backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
142
- * rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
143
- * `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
144
- * never a throw — a drift check that raises in place of its report hands the caller an exception
145
- * where a verdict was asked for. The constraint is still named, because it is the only thing
146
- * identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
147
- * runs this string either way.
148
- */
149
- export function rebuildForeignKey(
150
- table: string,
151
- declared: ForeignKeyDescription,
152
- held: ForeignKeyDescription,
153
- ): string {
154
- // The writers are ASKED whether they can write the pair — never a second copy of their rules
155
- // beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
156
- // the thrown value.
157
- try {
158
- return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
159
- } catch {
160
- return (
161
- `drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
162
- 'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
163
- 'write this pair'
164
- );
165
- }
166
- }
package/src/generate.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './introspect';
24
24
  import { declaredIndexes } from './invariant-ddl';
25
25
  import { migrationIrreversible } from './migration-errors';
26
+ import { addChangedKey, dropChangedKey } from './primary-key';
26
27
  import type { ReplicaIdentityInput } from './replica-identity';
27
28
  import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
28
29
  import type { MovedAside } from './retype-dependents';
@@ -324,6 +325,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
324
325
  created.add(entity.table);
325
326
  continue;
326
327
  }
328
+ // The key first and last, around every column statement of the table (`primary-key.ts`): the
329
+ // snapshot below records `entity.primaryKey`, and until this arm existed nothing produced it.
330
+ dropChangedKey(entity, live, current, plan, options.name);
327
331
  diffTable(entity, live, plan, retypedIn(retyped, entity.table));
328
332
  const kept = new Set(entity.columns.map((column) => column.column));
329
333
  for (const column of live.columns) {
@@ -343,6 +347,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
343
347
  ' -- data is not restored',
344
348
  );
345
349
  }
350
+ addChangedKey(entity, live, plan, options.name);
346
351
  }
347
352
 
348
353
  const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
package/src/index.ts CHANGED
@@ -72,7 +72,6 @@ export {
72
72
  DB_ERROR_RETRY,
73
73
  DB_ERROR_TITLES,
74
74
  DbError,
75
- dbNotImplemented,
76
75
  dbUnavailable,
77
76
  driverError,
78
77
  identifierUnsafe,
@@ -185,6 +184,7 @@ export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './repli
185
184
  export { type DbNode, isPlainRead } from './replica-route';
186
185
  export type { ReplicaScope } from './replica-scope';
187
186
  export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
187
+ export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
188
188
  export { snapshotJson } from './snapshot-json';
189
189
  export { parseSnapshot } from './snapshot-parse';
190
190
  export type { SqlFragment } from './sql';
@@ -203,8 +203,10 @@ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode }
203
203
  export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
204
204
  export { STATEMENT_ATTRIBUTE } from './statement-span';
205
205
  export { statementsOf } from './statement-split';
206
- export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
207
- export { beginStatement, currentTx, withTransaction } from './transaction';
206
+ export { currentTx, liveTxConnection, withTransaction } from './transaction';
207
+ export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
208
+ export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
209
+ export { beginStatement } from './transaction-options';
208
210
  export type { GeneratableForm } from './ungeneratable';
209
211
  export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
210
212
  export type { UnrenderedDeclaration } from './unrendered';
@@ -79,6 +79,12 @@ export async function introspectCatalog(
79
79
  const unrendered = await unrenderedRows(client, schema);
80
80
  const known = new Set(tables.map((table) => table.name));
81
81
  const materialized = new Set(views.filter((view) => view.kind === 'm').map((view) => view.name));
82
+ // A trigger loads only onto a relation the dump creates: a plain table, or a view (`instead
83
+ // of`). One on a partitioned table or a partition was filed under `09_triggers/` for a table no
84
+ // file creates, and the load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
85
+ const viewNames = new Set(views.map((view) => view.name));
86
+ const loadable = (trigger: { readonly table_name: string }): boolean =>
87
+ known.has(trigger.table_name) || viewNames.has(trigger.table_name);
82
88
 
83
89
  return {
84
90
  schema,
@@ -129,6 +135,7 @@ export async function introspectCatalog(
129
135
  ),
130
136
  ),
131
137
  triggers: triggers
138
+ .filter(loadable)
132
139
  .map((row) => ({
133
140
  table: row.table_name,
134
141
  name: row.name,
@@ -141,7 +148,17 @@ export async function introspectCatalog(
141
148
  (trigger) => trigger.name,
142
149
  ),
143
150
  ),
144
- unrendered: unrendered
151
+ unrendered: [
152
+ ...unrendered,
153
+ // Named rather than dropped, the rule `CatalogUnrendered` states.
154
+ ...triggers
155
+ .filter((trigger) => !loadable(trigger))
156
+ .map((trigger) => ({
157
+ kind: 'trigger',
158
+ name: trigger.name,
159
+ table_name: trigger.table_name,
160
+ })),
161
+ ]
145
162
  .map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
146
163
  .sort(
147
164
  by(