@ultimat3/db 14.0.0 → 16.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 +261 -5
- package/README.md +13 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +17 -5
- package/src/dependent-view.ts +224 -0
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -182
- package/src/errors.ts +2 -100
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +53 -172
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +188 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +9 -6
- package/src/introspect.ts +46 -1
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-dependents.ts +135 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
package/src/drift.ts
CHANGED
|
@@ -4,198 +4,35 @@
|
|
|
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 type { DriftDifference } from './drift-findings';
|
|
8
|
+
import {
|
|
9
|
+
changedColumn,
|
|
10
|
+
changedForeignKey,
|
|
11
|
+
changedIndex,
|
|
12
|
+
missingCheck,
|
|
13
|
+
missingColumn,
|
|
14
|
+
missingForeignKey,
|
|
15
|
+
missingIndex,
|
|
16
|
+
missingTable,
|
|
17
|
+
unexpectedColumn,
|
|
18
|
+
unexpectedTable,
|
|
19
|
+
unknownSchema,
|
|
20
|
+
} from './drift-findings';
|
|
7
21
|
import { DbError } from './errors';
|
|
8
|
-
import { foreignKeyTarget, onDeleteRule
|
|
22
|
+
import { foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
9
23
|
import { indexMethodOf } from './index-method';
|
|
10
|
-
import {
|
|
11
|
-
type ForeignKeyDescription,
|
|
12
|
-
findTable,
|
|
13
|
-
introspect,
|
|
14
|
-
type SchemaDescription,
|
|
15
|
-
type TableDescription,
|
|
16
|
-
} from './introspect';
|
|
24
|
+
import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
|
|
17
25
|
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
18
26
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
| 'changed-column'
|
|
23
|
-
| 'unexpected-table'
|
|
24
|
-
| 'missing-table'
|
|
25
|
-
| 'unknown-schema'
|
|
26
|
-
| 'missing-index'
|
|
27
|
-
| 'changed-index'
|
|
28
|
-
| 'missing-foreign-key'
|
|
29
|
-
| 'changed-foreign-key';
|
|
30
|
-
|
|
31
|
-
export interface DriftDifference {
|
|
32
|
-
readonly kind: DriftKind;
|
|
33
|
-
readonly table: string;
|
|
34
|
-
readonly column: string | null;
|
|
35
|
-
readonly cause: string;
|
|
36
|
-
readonly fix: string;
|
|
37
|
-
}
|
|
27
|
+
// Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
|
|
28
|
+
// split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
|
|
29
|
+
export type { DriftDifference, DriftKind } from './drift-findings';
|
|
38
30
|
|
|
39
31
|
export interface DriftReport {
|
|
40
32
|
readonly ok: boolean;
|
|
41
33
|
readonly differences: readonly DriftDifference[];
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
function unexpectedColumn(table: string, column: string): DriftDifference {
|
|
45
|
-
return {
|
|
46
|
-
kind: 'unexpected-column',
|
|
47
|
-
table,
|
|
48
|
-
column,
|
|
49
|
-
// Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
|
|
50
|
-
cause: `table "${table}" has column "${column}" not present in any migration`,
|
|
51
|
-
fix: `x db gen "add ${column}"`,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function missingColumn(table: string, column: string): DriftDifference {
|
|
56
|
-
return {
|
|
57
|
-
kind: 'missing-column',
|
|
58
|
-
table,
|
|
59
|
-
column,
|
|
60
|
-
cause: `table "${table}" is missing column "${column}" that migrations declare`,
|
|
61
|
-
fix: 'x db migrate',
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The column exists on both sides and one of them lets it be `NULL`.
|
|
67
|
-
*
|
|
68
|
-
* This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT
|
|
69
|
-
* NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the
|
|
70
|
-
* strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing
|
|
71
|
-
* a human has to remember. Nobody did, and `compareTable` compared columns by name and by type
|
|
72
|
-
* while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever
|
|
73
|
-
* against an entity schema that said otherwise, with `ok: true` on every check. The first
|
|
74
|
-
* `undefined` write then lands as `NULL` and crashes three services away from the migration.
|
|
75
|
-
*
|
|
76
|
-
* `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
|
|
77
|
-
* `set not null`, so naming it would send a reader to a command that generates an empty migration.
|
|
78
|
-
*/
|
|
79
|
-
function changedColumn(table: string, column: string, liveNullable: boolean): DriftDifference {
|
|
80
|
-
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
81
|
-
return {
|
|
82
|
-
kind: 'changed-column',
|
|
83
|
-
table,
|
|
84
|
-
column,
|
|
85
|
-
cause: liveNullable
|
|
86
|
-
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
87
|
-
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
88
|
-
fix:
|
|
89
|
-
`alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
|
|
90
|
-
(liveNullable ? ' — backfill the existing NULLs first' : ''),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function unexpectedTable(table: string): DriftDifference {
|
|
95
|
-
return {
|
|
96
|
-
kind: 'unexpected-table',
|
|
97
|
-
table,
|
|
98
|
-
column: null,
|
|
99
|
-
cause: `table "${table}" is not present in any migration`,
|
|
100
|
-
fix: `x db gen "add ${table}"`,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function missingTable(table: string): DriftDifference {
|
|
105
|
-
return {
|
|
106
|
-
kind: 'missing-table',
|
|
107
|
-
table,
|
|
108
|
-
column: null,
|
|
109
|
-
cause: `table "${table}" is declared by migrations but does not exist`,
|
|
110
|
-
fix: 'x db migrate',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Not a difference between two schemas but the absence of one to compare against — reported
|
|
116
|
-
* through the same channel so it reaches an operator, since a check that quietly answered "clean"
|
|
117
|
-
* because it had nothing to check is the one failure mode drift detection cannot have.
|
|
118
|
-
*/
|
|
119
|
-
function unknownSchema(migrations: readonly Migration[]): DriftDifference {
|
|
120
|
-
const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
|
|
121
|
-
return {
|
|
122
|
-
kind: 'unknown-schema',
|
|
123
|
-
table: '',
|
|
124
|
-
column: null,
|
|
125
|
-
cause:
|
|
126
|
-
`migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
|
|
127
|
-
'cannot be established',
|
|
128
|
-
// The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
|
|
129
|
-
// the same condition. It used to lead with `x db gen`, which raises that error and whose own
|
|
130
|
-
// fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
|
|
131
|
-
// pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
|
|
132
|
-
fix:
|
|
133
|
-
`git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
|
|
134
|
-
`delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function missingIndex(table: string, index: string): DriftDifference {
|
|
139
|
-
return {
|
|
140
|
-
kind: 'missing-index',
|
|
141
|
-
table,
|
|
142
|
-
column: null,
|
|
143
|
-
cause: `table "${table}" is missing index "${index}" that migrations declare`,
|
|
144
|
-
fix: 'x db migrate',
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function changedIndex(table: string, index: string, detail: string): DriftDifference {
|
|
149
|
-
return {
|
|
150
|
-
kind: 'changed-index',
|
|
151
|
-
table,
|
|
152
|
-
column: null,
|
|
153
|
-
cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
|
|
154
|
-
fix: 'x db migrate',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
|
|
159
|
-
return {
|
|
160
|
-
kind: 'missing-foreign-key',
|
|
161
|
-
table,
|
|
162
|
-
column: null,
|
|
163
|
-
cause:
|
|
164
|
-
`table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
|
|
165
|
-
`"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
|
|
166
|
-
fix: 'x db migrate',
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* The key points where it was declared to point and one side's `on delete` rule is not the other's
|
|
172
|
-
* — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
|
|
173
|
-
* there, and what changed is what happens to the child rows.
|
|
174
|
-
*
|
|
175
|
-
* The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
|
|
176
|
-
* alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
|
|
177
|
-
* naming a command would send a reader to one that generates an empty migration. Same reasoning
|
|
178
|
-
* as `changedColumn`.
|
|
179
|
-
*/
|
|
180
|
-
function changedForeignKey(
|
|
181
|
-
table: string,
|
|
182
|
-
declared: ForeignKeyDescription,
|
|
183
|
-
held: ForeignKeyDescription,
|
|
184
|
-
): DriftDifference {
|
|
185
|
-
const rule = onDeleteRule(held.onDelete);
|
|
186
|
-
return {
|
|
187
|
-
kind: 'changed-foreign-key',
|
|
188
|
-
table,
|
|
189
|
-
column: null,
|
|
190
|
-
cause:
|
|
191
|
-
`foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
192
|
-
`"${declared.referencedTable}" ` +
|
|
193
|
-
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
194
|
-
'migrations declare',
|
|
195
|
-
fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
36
|
/**
|
|
200
37
|
* Indexes migrations declare, against the ones the catalog holds — by column list and by
|
|
201
38
|
* uniqueness, which is what caught a composite index rebuilt with its columns the other way round
|
|
@@ -314,6 +151,33 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
|
|
|
314
151
|
return differences;
|
|
315
152
|
}
|
|
316
153
|
|
|
154
|
+
/**
|
|
155
|
+
* CHECK constraints migrations declare, against the NAMES the catalog holds — `checkNames`, which
|
|
156
|
+
* is a separate field from `checks` precisely so this comparison cannot reach a definition it must
|
|
157
|
+
* not read (`introspect.ts`).
|
|
158
|
+
*
|
|
159
|
+
* Two absences, and they mean opposite things. `expected.checks` absent is a sidecar written
|
|
160
|
+
* before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames`
|
|
161
|
+
* absent is a description that never asked the catalog — a stub, a fake client's rows, a
|
|
162
|
+
* `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. `introspect()` always answers with
|
|
164
|
+
* the field, `[]` included, so a real read is never mistaken for an unread one.
|
|
165
|
+
*
|
|
166
|
+
* Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state:
|
|
167
|
+
* a NOT NULL, an `enumerated()` column's old anonymous form, a constraint an extension brought and
|
|
168
|
+
* every hand-written CHECK an app has ever added would each be a finding against a database that
|
|
169
|
+
* is exactly right.
|
|
170
|
+
*/
|
|
171
|
+
function compareChecks(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
172
|
+
const declared = expected.checks;
|
|
173
|
+
const held = live.checkNames;
|
|
174
|
+
if (declared === undefined || held === undefined) return [];
|
|
175
|
+
const present = new Set(held);
|
|
176
|
+
return declared
|
|
177
|
+
.filter((check) => !present.has(check.name))
|
|
178
|
+
.map((check) => missingCheck(live.name, check));
|
|
179
|
+
}
|
|
180
|
+
|
|
317
181
|
/**
|
|
318
182
|
* A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
|
|
319
183
|
* adds the constraint with the key. Both sides are therefore read through the union of the two
|
|
@@ -350,6 +214,7 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
|
|
|
350
214
|
}
|
|
351
215
|
}
|
|
352
216
|
differences.push(...compareIndexes(live, expected));
|
|
217
|
+
differences.push(...compareChecks(live, expected));
|
|
353
218
|
differences.push(...compareForeignKeys(live, expected));
|
|
354
219
|
return differences;
|
|
355
220
|
}
|
package/src/errors.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
stringField,
|
|
12
12
|
UltimateError,
|
|
13
13
|
} from '@ultimat3/core';
|
|
14
|
-
import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
|
|
15
14
|
import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
|
|
16
15
|
|
|
17
16
|
/**
|
|
@@ -31,6 +30,7 @@ export const DB_OWNED_ERROR_CODES = [
|
|
|
31
30
|
'X_MIGRATION_IRREVERSIBLE',
|
|
32
31
|
'X_MIGRATION_DESTRUCTIVE',
|
|
33
32
|
'X_MIGRATION_SNAPSHOT_MISSING',
|
|
33
|
+
'X_MIGRATION_VIEW_DEPENDS',
|
|
34
34
|
'X_MIGRATE_CONCURRENT',
|
|
35
35
|
'X_SQL_UNSAFE',
|
|
36
36
|
'X_BRANCH_EXISTS',
|
|
@@ -72,6 +72,7 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
|
72
72
|
X_MIGRATION_IRREVERSIBLE: 'this migration cannot be reversed without data loss',
|
|
73
73
|
X_MIGRATION_DESTRUCTIVE: 'this migration destroys data and does not say so',
|
|
74
74
|
X_MIGRATION_SNAPSHOT_MISSING: 'the newest migration records no schema snapshot',
|
|
75
|
+
X_MIGRATION_VIEW_DEPENDS: 'a view is compiled against a column this migration retypes',
|
|
75
76
|
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
76
77
|
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
77
78
|
};
|
|
@@ -284,26 +285,6 @@ export const serializationExhausted = (attempts: number, sourceError: unknown):
|
|
|
284
285
|
sourceError,
|
|
285
286
|
});
|
|
286
287
|
|
|
287
|
-
/**
|
|
288
|
-
* The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
|
|
289
|
-
* no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
|
|
290
|
-
* left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
|
|
291
|
-
* failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
|
|
292
|
-
* exit code.
|
|
293
|
-
*/
|
|
294
|
-
export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
|
|
295
|
-
new DbError({
|
|
296
|
-
code: 'X_MIGRATE_CONCURRENT',
|
|
297
|
-
cause:
|
|
298
|
-
`another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
|
|
299
|
-
'so this migrator refused rather than block a deploy forever',
|
|
300
|
-
fix:
|
|
301
|
-
'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
|
|
302
|
-
"join pg_locks using (pid) where locktype = 'advisory'\"" +
|
|
303
|
-
' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
|
|
304
|
-
meta: { lockKey, waitedMs },
|
|
305
|
-
});
|
|
306
|
-
|
|
307
288
|
/** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
|
|
308
289
|
export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
309
290
|
new DbError({
|
|
@@ -313,85 +294,6 @@ export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
|
313
294
|
meta: { table: tableName, column: columnName },
|
|
314
295
|
});
|
|
315
296
|
|
|
316
|
-
export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
317
|
-
new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
|
|
318
|
-
|
|
319
|
-
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
320
|
-
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
|
|
324
|
-
* negative count counts from the END: `steps: -1` selected every applied migration except the
|
|
325
|
-
* newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
|
|
326
|
-
* Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
|
|
327
|
-
* as a different one is the failure a validated argument exists to prevent.
|
|
328
|
-
*/
|
|
329
|
-
export const rollbackStepsInvalid = (received: number): DbError =>
|
|
330
|
-
new DbError({
|
|
331
|
-
code: 'X_INVARIANT',
|
|
332
|
-
cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
|
|
333
|
-
fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
|
|
334
|
-
meta: { steps: received },
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
339
|
-
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
|
340
|
-
* rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
|
|
341
|
-
* keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
|
|
342
|
-
*/
|
|
343
|
-
const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
|
|
347
|
-
* and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
|
|
348
|
-
* the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
|
|
349
|
-
*/
|
|
350
|
-
const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* The sidecar every generated migration writes is what the *next* generation diffs against, so a
|
|
354
|
-
* newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
|
|
355
|
-
* schema, which would generate `create table` for every table the database already holds.
|
|
356
|
-
*/
|
|
357
|
-
export const migrationSnapshotMissing = (id: string, file: string): DbError =>
|
|
358
|
-
new DbError({
|
|
359
|
-
code: 'X_MIGRATION_SNAPSHOT_MISSING',
|
|
360
|
-
cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
|
|
361
|
-
// Two remedies, both commands, in the order they are safe to try. "restore from version
|
|
362
|
-
// control" alone was neither: on a scaffolded app the sidecar was never written, so there is
|
|
363
|
-
// nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
|
|
364
|
-
// the two errors pointed at each other and an app's first migration had no way out.
|
|
365
|
-
// `x db gen` is named only *after* the files it would trip over are gone.
|
|
366
|
-
fix:
|
|
367
|
-
`git checkout -- ${file} # or, if it was never written: ` +
|
|
368
|
-
`rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
|
|
369
|
-
meta: { id, file },
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* One error per file, never one per statement: the marker declares the whole migration, so a
|
|
374
|
-
* second finding would repeat an instruction the first already gave. `file` is app-relative and
|
|
375
|
-
* arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
|
|
376
|
-
*
|
|
377
|
-
* Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
|
|
378
|
-
* a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
|
|
379
|
-
* destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
|
|
380
|
-
*/
|
|
381
|
-
export const migrationDestructive = (
|
|
382
|
-
file: string,
|
|
383
|
-
first: DestructiveStatement,
|
|
384
|
-
more = 0,
|
|
385
|
-
): DbError =>
|
|
386
|
-
new DbError({
|
|
387
|
-
code: 'X_MIGRATION_DESTRUCTIVE',
|
|
388
|
-
cause:
|
|
389
|
-
`${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
|
|
390
|
-
`${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
|
|
391
|
-
fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
|
|
392
|
-
meta: { file, kind: first.kind, statements: more + 1 },
|
|
393
|
-
});
|
|
394
|
-
|
|
395
297
|
export const sqlUnsafe = (received: string, position: number): DbError =>
|
|
396
298
|
new DbError({
|
|
397
299
|
code: 'X_SQL_UNSAFE',
|
package/src/foreign-key-plan.ts
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
// BEFORE them. `generate.ts` assembles the plan; `foreign-key.ts` writes the SQL.
|
|
4
4
|
|
|
5
5
|
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
addForeignKey,
|
|
8
|
+
dropForeignKey,
|
|
9
|
+
foreignKeyTarget,
|
|
10
|
+
keyId,
|
|
11
|
+
onDeleteRule,
|
|
12
|
+
unrestorableNote,
|
|
13
|
+
} from './foreign-key';
|
|
7
14
|
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
-
import { identifier } from './sql';
|
|
9
15
|
|
|
10
16
|
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
11
17
|
export interface Plan {
|
|
@@ -73,19 +79,31 @@ export function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescript
|
|
|
73
79
|
* Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
|
|
74
80
|
* while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
|
|
75
81
|
* database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
|
|
76
|
-
* declared side, so no drift check could ever see it.
|
|
77
|
-
* leaves the snapshot correct by omission
|
|
82
|
+
* declared side, so no drift check could ever see it. This comment used to add "not parity with a
|
|
83
|
+
* removed index either: that leaves the snapshot correct by omission", which was **wrong** — a
|
|
84
|
+
* removed index lied in the identical way, and `index-plan.ts` is the arm that closed it. The drop names the constraint the previous snapshot
|
|
78
85
|
* recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
|
|
79
86
|
* `42704` under the generated spelling.
|
|
87
|
+
*
|
|
88
|
+
* `plans.predropped` names the constraints a RETYPE already took out of the way (`retype-keys.ts`),
|
|
89
|
+
* and it is read as "the schema does not record this key" — the same reading `checkPlan` gives its
|
|
90
|
+
* own `predropped` set. That is what makes this function the one writer of an `add constraint`
|
|
91
|
+
* after a retype: a key still declared is added back here, in the bucket that already runs after
|
|
92
|
+
* every table statement; one the entity dropped is left where the retype left it, because dropping
|
|
93
|
+
* it a second time is `42704`; and one whose `on delete` moved comes back carrying the new rule.
|
|
94
|
+
* Its `down` is the retype's, pushed where reversal puts it after both ends are back.
|
|
80
95
|
*/
|
|
81
96
|
export function foreignKeyPlan(
|
|
82
97
|
entity: EntityDescriptionLike,
|
|
83
98
|
live: TableDescription | undefined,
|
|
84
99
|
plans: ConstraintPlans,
|
|
85
100
|
): void {
|
|
86
|
-
const { constraints, preDrops, doomed } = plans;
|
|
101
|
+
const { constraints, preDrops, doomed, predropped } = plans;
|
|
87
102
|
const wanted = foreignKeysOf(entity);
|
|
88
|
-
const
|
|
103
|
+
const recordedKeys = (live?.foreignKeys ?? []).filter(
|
|
104
|
+
(key) => !predropped.has(keyId(entity.table, key.name)),
|
|
105
|
+
);
|
|
106
|
+
const held = new Map(recordedKeys.map((key) => [foreignKeyTarget(key), key]));
|
|
89
107
|
for (const key of wanted) {
|
|
90
108
|
const recorded = held.get(foreignKeyTarget(key));
|
|
91
109
|
if (doomed.has(key.referencedTable)) {
|
|
@@ -115,7 +133,7 @@ export function foreignKeyPlan(
|
|
|
115
133
|
}
|
|
116
134
|
const declared = new Set(wanted.map(foreignKeyTarget));
|
|
117
135
|
const columns = new Set(entity.columns.map((column) => column.column));
|
|
118
|
-
for (const key of
|
|
136
|
+
for (const key of recordedKeys) {
|
|
119
137
|
if (declared.has(foreignKeyTarget(key))) continue;
|
|
120
138
|
// `drop column` takes the constraint with it, so a `drop constraint` after that statement is
|
|
121
139
|
// `42704` on a constraint that is already gone.
|
|
@@ -136,19 +154,22 @@ export interface ConstraintPlans {
|
|
|
136
154
|
readonly preDrops: Plan;
|
|
137
155
|
/** The tables this migration drops, by name. */
|
|
138
156
|
readonly doomed: ReadonlySet<string>;
|
|
157
|
+
/**
|
|
158
|
+
* Recorded keys a retype already dropped ahead of the ALTERs, by `keyId` (`retype-keys.ts`).
|
|
159
|
+
* Read as "not recorded", never as "leave it alone": the declared side still needs its
|
|
160
|
+
* `add constraint`, and it is this function that writes it.
|
|
161
|
+
*/
|
|
162
|
+
readonly predropped: ReadonlySet<string>;
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
/**
|
|
142
166
|
* A key whose target is being dropped: gone on the way up, a note on the way back.
|
|
143
167
|
*
|
|
144
|
-
* The note
|
|
145
|
-
*
|
|
146
|
-
*
|
|
168
|
+
* The note itself is `unrestorableNote` (`foreign-key.ts`) and not a string built here —
|
|
169
|
+
* `retype-keys.ts` says the same thing about the same failed rollback, and two spellings of one
|
|
170
|
+
* fact is whichever module emitted last deciding what an operator reads.
|
|
147
171
|
*/
|
|
148
172
|
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
149
173
|
preDrops.up.push(dropForeignKey(table, constraint));
|
|
150
|
-
preDrops.down.push(
|
|
151
|
-
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
152
|
-
`cannot be restored; ${identifier(target).text} is gone`,
|
|
153
|
-
);
|
|
174
|
+
preDrops.down.push(unrestorableNote(table, constraint, target));
|
|
154
175
|
}
|
package/src/foreign-key.ts
CHANGED
|
@@ -52,6 +52,19 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
|
|
|
52
52
|
return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Which constraint, on which table — a key's NAME, where `foreignKeyTarget` is its meaning.
|
|
57
|
+
*
|
|
58
|
+
* The two exist for opposite questions and neither substitutes for the other. Drift asks whether
|
|
59
|
+
* two keys point the same way and must ignore the name; a plan that has already DROPPED a
|
|
60
|
+
* constraint asks whether this is that exact constraint, which is the name and nothing else. The
|
|
61
|
+
* table is in it because two tables may each hold a `..._org_id_fkey`, and `checkPlan`'s
|
|
62
|
+
* `predropped` set is the same shape one file over.
|
|
63
|
+
*/
|
|
64
|
+
export function keyId(table: string, constraint: string): string {
|
|
65
|
+
return JSON.stringify([table, constraint]);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
|
|
57
70
|
* now goes through. A name that closes its own quote produced a real `drop table` through
|
|
@@ -98,6 +111,26 @@ export function dropForeignKey(table: string, constraint: string): string {
|
|
|
98
111
|
return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What a `down` says in place of an `add constraint` it cannot run: the key's table or its target
|
|
116
|
+
* is dropped by this migration, so there is nothing to add the constraint back onto.
|
|
117
|
+
*
|
|
118
|
+
* ONE text, two writers — `foreign-key-plan.ts`'s `unrestorableDrop` (a key pointing at a doomed
|
|
119
|
+
* table) and `retype-keys.ts`'s `restore` (a key a retype moved aside whose ends are doomed).
|
|
120
|
+
* They spelled the same fact two ways and had already drifted, so an operator reading a failed
|
|
121
|
+
* rollback saw whichever module emitted last. It lives here because both import this module and
|
|
122
|
+
* neither imports the other.
|
|
123
|
+
*
|
|
124
|
+
* Every name goes through `identifier`, including `gone`: a `--` comment ends at the first
|
|
125
|
+
* newline, so a name holding one puts a second command on the line after it.
|
|
126
|
+
*/
|
|
127
|
+
export function unrestorableNote(table: string, constraint: string, gone: string): string {
|
|
128
|
+
return (
|
|
129
|
+
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
130
|
+
`cannot be restored; ${identifier(gone).text} is gone`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
/**
|
|
102
135
|
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
|
|
103
136
|
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
|