@ultimat3/db 23.0.0 → 25.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 +83 -85
- package/README.md +88 -27
- package/package.json +3 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/catalog-fold.ts +4 -1
- package/src/catalog-objects.ts +29 -0
- package/src/catalog.ts +10 -2
- package/src/client.ts +2 -2
- package/src/column-alter.ts +9 -3
- package/src/commit-tag.ts +21 -0
- package/src/default-client.ts +4 -4
- package/src/dependent-view.ts +7 -5
- package/src/destructive.ts +1 -1
- package/src/drift-append-only.ts +53 -0
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +211 -59
- package/src/drift.ts +33 -19
- package/src/entity-shape.ts +5 -0
- package/src/errors.ts +12 -8
- package/src/fake.ts +1 -1
- package/src/foreign-key.ts +0 -34
- package/src/generate-append-only.ts +146 -0
- package/src/generate.ts +20 -6
- package/src/index.ts +11 -9
- package/src/introspect-catalog.ts +19 -2
- package/src/introspect.ts +92 -12
- package/src/migrate-rollback.ts +44 -0
- package/src/migrate.ts +48 -158
- package/src/migration-ledger.ts +167 -0
- package/src/object-drift.ts +77 -20
- package/src/pglite-branch.ts +7 -7
- package/src/pglite.ts +16 -4
- package/src/pool-profile.ts +1 -1
- package/src/primary-key.ts +210 -0
- package/src/schema-dump-table.ts +4 -1
- package/src/sibling-turn.ts +49 -0
- package/src/snapshot-parse.ts +9 -3
- package/src/sqlstate.ts +30 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
- package/src/drift-fixtures.ts +0 -23
- package/src/fake-pglite.ts +0 -32
- package/src/fake-reservable.ts +0 -50
package/src/drift-findings.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
// Single responsibility: what a schema difference is CALLED and what its `fix:`
|
|
2
|
-
// constructor per `DriftKind`, and nothing that compares anything
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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,9 +19,12 @@ 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'
|
|
26
|
+
// Constructed in `drift-append-only.ts`: an `appendOnly` table whose refusing trigger is gone.
|
|
27
|
+
| 'missing-append-only-trigger'
|
|
31
28
|
// Constructed in `object-drift.ts`: a trigger, function, view, type or sequence in the live
|
|
32
29
|
// database that replaying the migrations does not create.
|
|
33
30
|
| 'unexpected-object';
|
|
@@ -45,6 +42,66 @@ export interface DriftReport {
|
|
|
45
42
|
readonly differences: readonly DriftDifference[];
|
|
46
43
|
}
|
|
47
44
|
|
|
45
|
+
const RE_CHECK = 'then x db migrate, which re-checks';
|
|
46
|
+
|
|
47
|
+
/** `set not null` is refused by the server while a row still holds NULL, and says so here. */
|
|
48
|
+
const NULLS_FIRST = `refused while a row holds NULL there, so backfill those first; ${RE_CHECK}`;
|
|
49
|
+
|
|
50
|
+
const CARRIES =
|
|
51
|
+
'a name in this difference carries a backtick, a dollar sign, a quote, a backslash or whitespace';
|
|
52
|
+
|
|
53
|
+
const UNSPELLABLE = `${CARRIES}, so no statement here can spell it`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `x db migrate` is the fix where a migration has not been applied. Where it has, re-running the
|
|
57
|
+
* migrator applies nothing a ledger row already claims, so the fix is the statement itself —
|
|
58
|
+
* a repair made against THIS database, as one line a shell runs: the statement is `psql`'s
|
|
59
|
+
* argument (`psqlCommand`), never bare DDL beside a `#` — `#` is not a comment to Postgres and
|
|
60
|
+
* `alter` is not a program to a shell, so neither reader could run that line (axiom 4). Against
|
|
61
|
+
* this database and never "in a new migration": drift means this database left the migrations,
|
|
62
|
+
* and a migration would re-apply the repair to every database that is already right.
|
|
63
|
+
*/
|
|
64
|
+
export const repair = (path: string, statements: string, note = RE_CHECK): string =>
|
|
65
|
+
`${psqlCommand(`${path}${statements}`)} # ${note}`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same repair when no statement can be written — a name the screen refuses. Still a command
|
|
69
|
+
* that runs: a psql session, with what to do in it as the comment. No name rides in it, hostile
|
|
70
|
+
* or not; the `cause` holds them, and nobody pastes a cause.
|
|
71
|
+
*/
|
|
72
|
+
export const byHand = (steps: string, why = UNSPELLABLE): string =>
|
|
73
|
+
`psql "$DATABASE_URL" # ${steps}, \\q, ${RE_CHECK} — ${why}`;
|
|
74
|
+
|
|
75
|
+
/** The schema Postgres resolves an unqualified name in when a session sets nothing. */
|
|
76
|
+
const DEFAULT_SCHEMA = 'public';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What puts a statement in the schema its table was READ from: nothing for the default one — the
|
|
80
|
+
* text every app has seen — and `set search_path` in the same psql word for any other, so the
|
|
81
|
+
* table, and every table the statement references, resolves there. A `psql "$DATABASE_URL"`
|
|
82
|
+
* session starts on its own search_path: unqualified, `alter table "posts"` for a table in
|
|
83
|
+
* `tenant_a` fails, or lands on a same-named table in `public`. `null` for a schema no statement
|
|
84
|
+
* can spell.
|
|
85
|
+
*/
|
|
86
|
+
export const pathTo = (schema: string): string | null => {
|
|
87
|
+
if (schema === DEFAULT_SCHEMA) return '';
|
|
88
|
+
const name = shellInertIdentifier(schema);
|
|
89
|
+
return name === null ? null : `set search_path = ${name}; `;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A table as a psql PATTERN or a statement outside `pathTo`: qualified unless the default schema. */
|
|
93
|
+
const qualified = (schema: string, table: string): string | null => {
|
|
94
|
+
const name = shellInertIdentifier(table);
|
|
95
|
+
if (name === null) return null;
|
|
96
|
+
if (schema === DEFAULT_SCHEMA) return name;
|
|
97
|
+
const space = shellInertIdentifier(schema);
|
|
98
|
+
return space === null ? null : `${space}.${name}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Every name inert in a shell AND writable as an identifier — the one screen, asked of each. */
|
|
102
|
+
const spellable = (names: readonly string[]): boolean =>
|
|
103
|
+
names.every((name) => shellInertIdentifier(name) !== null);
|
|
104
|
+
|
|
48
105
|
/**
|
|
49
106
|
* The one `fix:` here whose second layer no quoting closes. `x db gen "add C"` puts the column
|
|
50
107
|
* inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick substitute before `x` is reached at all
|
|
@@ -93,13 +150,16 @@ export function missingColumn(table: string, column: string): DriftDifference {
|
|
|
93
150
|
*
|
|
94
151
|
* `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
|
|
95
152
|
* `set not null`, so naming it would send a reader to a command that generates an empty migration.
|
|
153
|
+
* The fix is the statement, run against this database (`repair`).
|
|
96
154
|
*/
|
|
97
155
|
export function changedColumn(
|
|
156
|
+
schema: string,
|
|
98
157
|
table: string,
|
|
99
158
|
column: string,
|
|
100
159
|
liveNullable: boolean,
|
|
101
160
|
): DriftDifference {
|
|
102
161
|
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
162
|
+
const path = pathTo(schema);
|
|
103
163
|
const relation = shellInertIdentifier(table);
|
|
104
164
|
const attribute = shellInertIdentifier(column);
|
|
105
165
|
return {
|
|
@@ -109,16 +169,15 @@ export function changedColumn(
|
|
|
109
169
|
cause: liveNullable
|
|
110
170
|
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
111
171
|
: `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.
|
|
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.
|
|
172
|
+
// Both identifiers are the catalog's, so both go through the one screen.
|
|
115
173
|
fix:
|
|
116
|
-
relation === null || attribute === null
|
|
117
|
-
?
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
174
|
+
path === null || relation === null || attribute === null
|
|
175
|
+
? byHand(`alter column … ${clause} on the column this difference names`)
|
|
176
|
+
: repair(
|
|
177
|
+
path,
|
|
178
|
+
`alter table ${relation} alter column ${attribute} ${clause};`,
|
|
179
|
+
liveNullable ? NULLS_FIRST : RE_CHECK,
|
|
180
|
+
),
|
|
122
181
|
};
|
|
123
182
|
}
|
|
124
183
|
|
|
@@ -134,22 +193,32 @@ export function changedColumn(
|
|
|
134
193
|
* the relation is already there, and `x db migrate` then accepts a table its own SQL creates), or
|
|
135
194
|
* nothing owns it and it should not be in this schema. No migration PATH is named: where an app
|
|
136
195
|
* keeps its migrations is the CLI's fact, not this package's.
|
|
196
|
+
*
|
|
197
|
+
* Two repairs and one line, so the line leads with the command neither repair can skip — `\\d` on
|
|
198
|
+
* the table, through `psqlCommand`, which is what keeps a `'` in the name inside its shell word.
|
|
137
199
|
*/
|
|
138
|
-
export function unexpectedTable(table: string): DriftDifference {
|
|
139
|
-
const name =
|
|
200
|
+
export function unexpectedTable(schema: string, table: string): DriftDifference {
|
|
201
|
+
const name = qualified(schema, table);
|
|
202
|
+
// The comment repeats the name only when it holds no `'`: a shell that does not read `#` as a
|
|
203
|
+
// comment (interactive zsh, by default) would open a quote on one. The command is safe either
|
|
204
|
+
// way — `psqlCommand` escapes it inside its own word.
|
|
205
|
+
const spoken = name === null || name.includes("'") ? 'this table' : name;
|
|
140
206
|
return {
|
|
141
207
|
kind: 'unexpected-table',
|
|
142
208
|
table,
|
|
143
209
|
column: null,
|
|
144
210
|
cause: `table "${table}" is not present in any migration`,
|
|
211
|
+
// The command is the harmless one — it SHOWS the table, which either repair needs first — and
|
|
212
|
+
// the two repairs are its comment.
|
|
145
213
|
fix:
|
|
146
214
|
name === null
|
|
147
|
-
?
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
215
|
+
? byHand(
|
|
216
|
+
`inspect the table this difference names with \\d, then claim it in a migration ` +
|
|
217
|
+
'with create table if not exists or drop it',
|
|
218
|
+
)
|
|
219
|
+
: `${psqlCommand(`\\d ${name}`)} # nothing declares it: put create table if not exists ` +
|
|
220
|
+
`${spoken} (…) in a migration, then x db migrate — or, if nothing owns it, run ` +
|
|
221
|
+
`drop table ${spoken}; here`,
|
|
153
222
|
};
|
|
154
223
|
}
|
|
155
224
|
|
|
@@ -176,7 +245,7 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
|
|
|
176
245
|
// id off the file and derives the name from it — so whoever can add a file to the migrations
|
|
177
246
|
// directory picks what a reader pastes, and `$(…)` and a backtick substitute before `git` or `x`
|
|
178
247
|
// is reached. The same screen `unexpectedColumn` and `changedColumn` already ran, on the one
|
|
179
|
-
// finding in this file that skipped it. Degraded to
|
|
248
|
+
// finding in this file that skipped it. Degraded to a read-only command rather than escaped: a glob is not an
|
|
180
249
|
// identifier and a migration description is not one either, so neither has a quoted form that
|
|
181
250
|
// makes a hostile name safe. An EMPTY id is inert by construction and keeps its glob — that is
|
|
182
251
|
// "no migrations at all", not a name this function refused to spell.
|
|
@@ -196,10 +265,10 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
|
|
|
196
265
|
fix: spellable
|
|
197
266
|
? `git checkout -- "*${id}.snapshot.json" # or, if it was never written: ` +
|
|
198
267
|
`delete migration "${id}" and rerun x db gen "${name}"`
|
|
199
|
-
: '
|
|
200
|
-
'migration and rerun x db gen with its description — the
|
|
201
|
-
"difference's cause carries a backtick, a dollar sign, a
|
|
202
|
-
'whitespace in its file name, so no command here can spell it',
|
|
268
|
+
: 'git status --short -- "*.snapshot.json" # shows the sidecar that is gone: git ' +
|
|
269
|
+
'checkout it, or delete that migration and rerun x db gen with its description — the ' +
|
|
270
|
+
"migration named in this difference's cause carries a backtick, a dollar sign, a " +
|
|
271
|
+
'quote, a backslash or whitespace in its file name, so no command here can spell it',
|
|
203
272
|
};
|
|
204
273
|
}
|
|
205
274
|
|
|
@@ -232,12 +301,17 @@ export function changedIndex(table: string, index: string, detail: string): Drif
|
|
|
232
301
|
* 'published'::text])))` — so a text comparison reports drift on a correct database forever, and
|
|
233
302
|
* normalising it is an expression parser competing with the server's. Presence is not text.
|
|
234
303
|
*
|
|
235
|
-
* The `fix` is the statement, not `x db migrate`: the migration that declares this
|
|
236
|
-
* already in the ledger, so re-running the migrator applies nothing. Same reasoning
|
|
237
|
-
* `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling
|
|
238
|
-
* the predicate, which is what makes an executable fix possible at all.
|
|
304
|
+
* The `fix` is the statement (`repair`), not `x db migrate`: the migration that declares this
|
|
305
|
+
* constraint is already in the ledger, so re-running the migrator applies nothing. Same reasoning
|
|
306
|
+
* as `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling
|
|
307
|
+
* of the predicate, which is what makes an executable fix possible at all.
|
|
239
308
|
*/
|
|
240
|
-
export function missingCheck(
|
|
309
|
+
export function missingCheck(
|
|
310
|
+
schema: string,
|
|
311
|
+
table: string,
|
|
312
|
+
check: CheckDescription,
|
|
313
|
+
): DriftDifference {
|
|
314
|
+
const path = pathTo(schema);
|
|
241
315
|
const relation = shellInertIdentifier(table);
|
|
242
316
|
const constraint = shellInertIdentifier(check.name);
|
|
243
317
|
return {
|
|
@@ -245,23 +319,18 @@ export function missingCheck(table: string, check: CheckDescription): DriftDiffe
|
|
|
245
319
|
table,
|
|
246
320
|
column: null,
|
|
247
321
|
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
322
|
// Both NAMES go through the one screen; the EXPRESSION deliberately does not, and cannot. It
|
|
254
323
|
// is a predicate, so no screen could accept `status in ('draft', 'published')` and reject a
|
|
255
324
|
// 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.
|
|
257
|
-
//
|
|
325
|
+
// where both names are the catalog's and a sidecar's. What `psqlCommand` does close is the
|
|
326
|
+
// shell layer: the statement is one single-quoted word, so nothing in the predicate expands.
|
|
258
327
|
fix:
|
|
259
|
-
relation === null || constraint === null
|
|
260
|
-
? 'add the constraint
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
328
|
+
path === null || relation === null || constraint === null
|
|
329
|
+
? byHand('add the check constraint this difference names back')
|
|
330
|
+
: repair(
|
|
331
|
+
path,
|
|
332
|
+
`alter table ${relation} add constraint ${constraint} check (${check.expression});`,
|
|
333
|
+
),
|
|
265
334
|
};
|
|
266
335
|
}
|
|
267
336
|
|
|
@@ -282,26 +351,109 @@ export function missingForeignKey(table: string, key: ForeignKeyDescription): Dr
|
|
|
282
351
|
* — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
|
|
283
352
|
* there, and what changed is what happens to the child rows.
|
|
284
353
|
*
|
|
285
|
-
* The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add
|
|
286
|
-
* alone is `42710` on a name already taken, and no `x db gen` diff emits either
|
|
287
|
-
*
|
|
288
|
-
*
|
|
354
|
+
* The `fix` is the pair (`repair`), not `x db migrate`: a rule cannot be altered in place, `add
|
|
355
|
+
* constraint` alone is `42710` on a name already taken, and no `x db gen` diff emits either
|
|
356
|
+
* statement. Same reasoning as `changedColumn`.
|
|
357
|
+
*
|
|
358
|
+
* `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s, so every name is
|
|
359
|
+
* screened and the two writers are ASKED whether they can write the pair — never a second copy of
|
|
360
|
+
* their rules beside them. `identifier()` refuses a name holding a quote, a space or a backslash
|
|
361
|
+
* and `addForeignKey` refuses an `on delete` rule Postgres does not have: right for DDL this
|
|
362
|
+
* package SENDS, wrong for a `fix:`. `diffSchema` is documented pure and total, so a pair it
|
|
363
|
+
* cannot write is a psql session and a sentence, never a throw.
|
|
364
|
+
*
|
|
365
|
+
* The CAUSE names the constraint the database holds, whatever it is called: a refused name is out
|
|
366
|
+
* of the command, and the cause is where a reader still finds which key this is.
|
|
289
367
|
*/
|
|
290
368
|
export function changedForeignKey(
|
|
369
|
+
schema: string,
|
|
291
370
|
table: string,
|
|
292
371
|
declared: ForeignKeyDescription,
|
|
293
372
|
held: ForeignKeyDescription,
|
|
294
373
|
): DriftDifference {
|
|
295
374
|
const rule = onDeleteRule(held.onDelete);
|
|
375
|
+
const rebuilt = (): string => {
|
|
376
|
+
const steps =
|
|
377
|
+
'drop the foreign key this difference names and add it back with the on delete rule ' +
|
|
378
|
+
'migrations declare';
|
|
379
|
+
const names = [
|
|
380
|
+
table,
|
|
381
|
+
held.name,
|
|
382
|
+
declared.name,
|
|
383
|
+
declared.referencedTable,
|
|
384
|
+
...declared.columns,
|
|
385
|
+
...declared.referencedColumns,
|
|
386
|
+
];
|
|
387
|
+
const path = pathTo(schema);
|
|
388
|
+
if (path === null || !spellable(names)) return byHand(steps);
|
|
389
|
+
try {
|
|
390
|
+
return repair(path, `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`);
|
|
391
|
+
} catch {
|
|
392
|
+
return byHand(steps, 'the rule migrations declare is not one Postgres has');
|
|
393
|
+
}
|
|
394
|
+
};
|
|
296
395
|
return {
|
|
297
396
|
kind: 'changed-foreign-key',
|
|
298
397
|
table,
|
|
299
398
|
column: null,
|
|
300
399
|
cause:
|
|
301
|
-
`foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
400
|
+
`foreign key "${held.name}" on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
302
401
|
`"${declared.referencedTable}" ` +
|
|
303
402
|
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
304
403
|
'migrations declare',
|
|
305
|
-
fix:
|
|
404
|
+
fix: rebuilt(),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const PRIMARY_KEY_BY_HAND = byHand(
|
|
409
|
+
'drop the primary key this database holds, add the one migrations declare',
|
|
410
|
+
`${CARRIES} or is too long, so no statement here can spell it`,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const keyText = (columns: readonly string[]): string =>
|
|
414
|
+
columns.length === 0 ? 'no primary key' : `primary key (${columns.join(', ')})`;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The two sides key the table differently — a different column list, a different ORDER, or a key
|
|
418
|
+
* on one side only. Its own kind rather than `changed-index` on `<table>_pkey`: that finding's fix
|
|
419
|
+
* is `x db migrate`, and the migration declaring this key is already in the ledger, so re-running
|
|
420
|
+
* the migrator applies nothing.
|
|
421
|
+
*
|
|
422
|
+
* The fix is ONE command a shell runs — the pair as `psql`'s argument (`repair`).
|
|
423
|
+
*
|
|
424
|
+
* `held` is the constraint the DATABASE holds — the live primary index's name, which is the
|
|
425
|
+
* constraint's — because that is the one a `drop constraint` has to spell. The writers are asked
|
|
426
|
+
* whether they can write each statement and a refusal degrades the whole line to prose, the rule
|
|
427
|
+
* `changedForeignKey` states: every name on the live side is the catalog's.
|
|
428
|
+
*/
|
|
429
|
+
export function changedPrimaryKey(
|
|
430
|
+
schema: string,
|
|
431
|
+
table: string,
|
|
432
|
+
live: readonly string[],
|
|
433
|
+
held: string | undefined,
|
|
434
|
+
declared: readonly string[],
|
|
435
|
+
): DriftDifference {
|
|
436
|
+
const statements = (): string => {
|
|
437
|
+
const names = [table, ...declared, ...(held === undefined ? [] : [held])];
|
|
438
|
+
const path = pathTo(schema);
|
|
439
|
+
if (path === null || !spellable(names)) return PRIMARY_KEY_BY_HAND;
|
|
440
|
+
try {
|
|
441
|
+
const parts: string[] = [];
|
|
442
|
+
if (held !== undefined) parts.push(dropPrimaryKey(table, held, false));
|
|
443
|
+
if (declared.length > 0) parts.push(addPrimaryKey(table, declared));
|
|
444
|
+
return repair(path, parts.join(' '));
|
|
445
|
+
} catch {
|
|
446
|
+
return PRIMARY_KEY_BY_HAND;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
kind: 'changed-primary-key',
|
|
451
|
+
table,
|
|
452
|
+
column: null,
|
|
453
|
+
cause:
|
|
454
|
+
`table "${table}" has ${keyText(live)}${held === undefined ? '' : ` as constraint "${held}"`}, ` +
|
|
455
|
+
'and migrations declare ' +
|
|
456
|
+
(declared.length === 0 ? 'none' : `(${declared.join(', ')})`),
|
|
457
|
+
fix: statements(),
|
|
306
458
|
};
|
|
307
459
|
}
|
package/src/drift.ts
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
// by the framework contract; `x verify` fails on it and `--json` carries every difference.
|
|
5
5
|
|
|
6
6
|
import { baseClient, type DbClient } from './client';
|
|
7
|
+
import { APPEND_ONLY_DRIFT_CODE, compareAppendOnly } from './drift-append-only';
|
|
7
8
|
import type { DriftDifference } from './drift-findings';
|
|
8
9
|
import {
|
|
9
10
|
changedColumn,
|
|
10
11
|
changedForeignKey,
|
|
11
12
|
changedIndex,
|
|
13
|
+
changedPrimaryKey,
|
|
12
14
|
missingCheck,
|
|
13
15
|
missingColumn,
|
|
14
16
|
missingForeignKey,
|
|
@@ -21,8 +23,14 @@ import {
|
|
|
21
23
|
import { DbError } from './errors';
|
|
22
24
|
import { foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
23
25
|
import { indexMethodOf } from './index-method';
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
findTable,
|
|
28
|
+
introspectSchema,
|
|
29
|
+
type SchemaDescription,
|
|
30
|
+
type TableDescription,
|
|
31
|
+
} from './introspect';
|
|
25
32
|
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
33
|
+
import { sameColumns } from './primary-key';
|
|
26
34
|
|
|
27
35
|
// Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
|
|
28
36
|
// split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
|
|
@@ -145,7 +153,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
|
|
|
145
153
|
continue;
|
|
146
154
|
}
|
|
147
155
|
if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
|
|
148
|
-
differences.push(changedForeignKey(live.name, key, counterpart));
|
|
156
|
+
differences.push(changedForeignKey(live.schema, live.name, key, counterpart));
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
159
|
return differences;
|
|
@@ -160,7 +168,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
|
|
|
160
168
|
* before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames`
|
|
161
169
|
* absent is a description that never asked the catalog — a stub, a fake client's rows, a
|
|
162
170
|
* `TableDescription` built by hand — and reading that as "the database holds none" is one finding
|
|
163
|
-
* per declared constraint against a database nobody looked at. `
|
|
171
|
+
* per declared constraint against a database nobody looked at. `introspectSchema()` always answers with
|
|
164
172
|
* the field, `[]` included, so a real read is never mistaken for an unread one.
|
|
165
173
|
*
|
|
166
174
|
* Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state:
|
|
@@ -175,26 +183,29 @@ function compareChecks(live: TableDescription, expected: TableDescription): Drif
|
|
|
175
183
|
const present = new Set(held);
|
|
176
184
|
return declared
|
|
177
185
|
.filter((check) => !present.has(check.name))
|
|
178
|
-
.map((check) => missingCheck(live.name, check));
|
|
186
|
+
.map((check) => missingCheck(live.schema, live.name, check));
|
|
179
187
|
}
|
|
180
188
|
|
|
181
189
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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.
|
|
190
|
+
* The two key lists, compared in ORDER. The comment that used to stand here said a key on one side
|
|
191
|
+
* only "is a difference the key comparison owns" — and no key comparison existed, so a table
|
|
192
|
+
* re-keyed by hand, or one whose key no migration ever produced, read `ok: true`.
|
|
188
193
|
*/
|
|
189
|
-
function
|
|
190
|
-
|
|
194
|
+
function comparePrimaryKey(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
195
|
+
if (sameColumns(live.primaryKey, expected.primaryKey)) return [];
|
|
196
|
+
const held = live.indexes.find((index) => index.primary)?.name;
|
|
197
|
+
return [changedPrimaryKey(live.schema, live.name, live.primaryKey, held, expected.primaryKey)];
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
194
201
|
const differences: DriftDifference[] = [];
|
|
195
202
|
const expectedColumns = new Map(expected.columns.map((column) => [column.name, column]));
|
|
196
203
|
const liveColumns = new Map(live.columns.map((column) => [column.name, column]));
|
|
197
|
-
|
|
204
|
+
// A primary key column is `NOT NULL` in the catalog whether or not anything declared it —
|
|
205
|
+
// Postgres adds the constraint with the key — so a snapshot spelling its key column nullable is
|
|
206
|
+
// not drift. The DECLARED key only: a column the database alone keys stays NOT NULL after the
|
|
207
|
+
// stray constraint is dropped, which is a second fault and reported as one.
|
|
208
|
+
const keyColumns = new Set(expected.primaryKey);
|
|
198
209
|
for (const column of live.columns) {
|
|
199
210
|
if (expectedColumns.has(column.name)) continue;
|
|
200
211
|
differences.push(unexpectedColumn(live.name, column.name));
|
|
@@ -210,12 +221,14 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
|
|
|
210
221
|
// `retypeColumn` already owns that question where both sides are generated.
|
|
211
222
|
if (keyColumns.has(column.name)) continue;
|
|
212
223
|
if (column.nullable !== counterpart.nullable) {
|
|
213
|
-
differences.push(changedColumn(live.name, column.name, counterpart.nullable));
|
|
224
|
+
differences.push(changedColumn(live.schema, live.name, column.name, counterpart.nullable));
|
|
214
225
|
}
|
|
215
226
|
}
|
|
227
|
+
differences.push(...comparePrimaryKey(live, expected));
|
|
216
228
|
differences.push(...compareIndexes(live, expected));
|
|
217
229
|
differences.push(...compareChecks(live, expected));
|
|
218
230
|
differences.push(...compareForeignKeys(live, expected));
|
|
231
|
+
differences.push(...compareAppendOnly(live, expected));
|
|
219
232
|
return differences;
|
|
220
233
|
}
|
|
221
234
|
|
|
@@ -224,7 +237,7 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
|
|
|
224
237
|
const differences: DriftDifference[] = [];
|
|
225
238
|
for (const table of live.tables) {
|
|
226
239
|
const counterpart = findTable(expected, table.name);
|
|
227
|
-
if (counterpart === undefined) differences.push(unexpectedTable(table.name));
|
|
240
|
+
if (counterpart === undefined) differences.push(unexpectedTable(table.schema, table.name));
|
|
228
241
|
else differences.push(...compareTable(table, counterpart));
|
|
229
242
|
}
|
|
230
243
|
for (const table of expected.tables) {
|
|
@@ -240,7 +253,8 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
|
|
|
240
253
|
|
|
241
254
|
export function driftError(difference: DriftDifference): DbError {
|
|
242
255
|
return new DbError({
|
|
243
|
-
code
|
|
256
|
+
// One kind carries its own code — the guarantee it names is the entity's, not a column's.
|
|
257
|
+
code: difference.kind === 'missing-append-only-trigger' ? APPEND_ONLY_DRIFT_CODE : 'X_DB_DRIFT',
|
|
244
258
|
cause: difference.cause,
|
|
245
259
|
fix: difference.fix,
|
|
246
260
|
meta: { kind: difference.kind, table: difference.table, column: difference.column },
|
|
@@ -254,7 +268,7 @@ export function driftError(difference: DriftDifference): DbError {
|
|
|
254
268
|
* (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
|
|
255
269
|
* which never reaches this function. There is no `x db drift` command.
|
|
256
270
|
*/
|
|
257
|
-
export function
|
|
271
|
+
export function assertNoSchemaDrift(report: DriftReport): void {
|
|
258
272
|
const first = report.differences[0];
|
|
259
273
|
if (first !== undefined) throw driftError(first);
|
|
260
274
|
}
|
|
@@ -305,7 +319,7 @@ export function expectedSchema(
|
|
|
305
319
|
* schema that is in fact correct. The `x_` prefix is the convention every framework table already
|
|
306
320
|
* follows, so a table a future package adds needs no second list here.
|
|
307
321
|
*
|
|
308
|
-
* `
|
|
322
|
+
* `introspectSchema()` keeps its own narrower default (`x_migrations` alone) on purpose: the admin
|
|
309
323
|
* dashboard's schema view and the MCP `schema.describe` tool legitimately show `x_users`. Only
|
|
310
324
|
* drift wants the whole namespace gone, so only drift declares it.
|
|
311
325
|
*/
|
|
@@ -342,7 +356,7 @@ export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
|
|
|
342
356
|
// and a wrong `ok: true` is the failure this check exists to prevent.
|
|
343
357
|
if (expected === undefined)
|
|
344
358
|
return { ok: false, differences: [unknownSchema(options.migrations)] };
|
|
345
|
-
const live = await
|
|
359
|
+
const live = await introspectSchema({
|
|
346
360
|
client,
|
|
347
361
|
...(options.schema === undefined ? {} : { schema: options.schema }),
|
|
348
362
|
});
|
package/src/entity-shape.ts
CHANGED
|
@@ -112,4 +112,9 @@ export interface EntityDescriptionLike {
|
|
|
112
112
|
* none", which is what every hand-built description in this package's own tests is.
|
|
113
113
|
*/
|
|
114
114
|
readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
|
|
115
|
+
/**
|
|
116
|
+
* `entity({ appendOnly: true })`: the table refuses UPDATE and DELETE (`generate-append-only.ts`).
|
|
117
|
+
* Optional for the reason `invariants` is, and absent reads as "rows may change".
|
|
118
|
+
*/
|
|
119
|
+
readonly appendOnly?: boolean | undefined;
|
|
115
120
|
}
|
package/src/errors.ts
CHANGED
|
@@ -38,6 +38,11 @@ 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',
|
|
44
|
+
'X_APPEND_ONLY_TRIGGER_MISSING',
|
|
45
|
+
'X_MIGRATION_APPEND_ONLY_BACKFILL',
|
|
41
46
|
] as const;
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -83,6 +88,12 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
|
83
88
|
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
84
89
|
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
85
90
|
X_SCHEMA_DUMP_DRIFT: 'the committed schema dump is not what the migrations produce',
|
|
91
|
+
X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
|
|
92
|
+
X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
|
|
93
|
+
X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
|
|
94
|
+
X_APPEND_ONLY_TRIGGER_MISSING: 'an append-only table has lost its refusing trigger',
|
|
95
|
+
X_MIGRATION_APPEND_ONLY_BACKFILL:
|
|
96
|
+
'a NOT NULL column added to an append-only table needs a default',
|
|
86
97
|
};
|
|
87
98
|
|
|
88
99
|
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
|
@@ -279,7 +290,7 @@ export const drainTimeout = (ms: number, role: string): DbError =>
|
|
|
279
290
|
new DbError({
|
|
280
291
|
code: 'X_DB_DRAIN_TIMEOUT',
|
|
281
292
|
cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
|
|
282
|
-
fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in
|
|
293
|
+
fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in postgresClient({ profile }) for the ${role} role`,
|
|
283
294
|
meta: { drainTimeoutMs: ms, role },
|
|
284
295
|
});
|
|
285
296
|
|
|
@@ -416,10 +427,3 @@ export const isolationLevelInvalid = (received: unknown): DbError =>
|
|
|
416
427
|
cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
|
|
417
428
|
fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
|
|
418
429
|
});
|
|
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
|
-
});
|
package/src/fake.ts
CHANGED
|
@@ -49,7 +49,7 @@ const DEFAULT_STUBS: readonly Stub[] = [
|
|
|
49
49
|
{ match: /pg_try_advisory_lock/, response: { rows: [{ locked: true }] } },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
-
export function
|
|
52
|
+
export function recordingClient(): RecordingClient {
|
|
53
53
|
const statements: RecordedStatement[] = [];
|
|
54
54
|
const stubs: Stub[] = [...DEFAULT_STUBS];
|
|
55
55
|
|
package/src/foreign-key.ts
CHANGED
|
@@ -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
|
-
}
|