@ultimat3/db 15.0.0 → 17.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 +249 -22
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +14 -232
- package/src/connection-url.ts +60 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/dependent-view.ts +224 -0
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +2 -109
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +24 -21
- package/src/migrate.ts +30 -12
- package/src/migration-errors.ts +132 -0
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +64 -10
package/src/drift-findings.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { onDeleteRule, rebuildForeignKey } from './foreign-key';
|
|
15
15
|
import type { CheckDescription, ForeignKeyDescription } from './introspect';
|
|
16
16
|
import type { Migration } from './migrate';
|
|
17
|
+
import { shellInertIdentifier } from './sql';
|
|
17
18
|
|
|
18
19
|
export type DriftKind =
|
|
19
20
|
| 'unexpected-column'
|
|
@@ -41,6 +42,15 @@ export interface DriftReport {
|
|
|
41
42
|
readonly differences: readonly DriftDifference[];
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The one `fix:` here whose second layer no quoting closes. `x db gen "add C"` puts the column
|
|
47
|
+
* inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick substitute before `x` is reached at all
|
|
48
|
+
* — and the argument is a migration DESCRIPTION, not an identifier, so there is no quoted form
|
|
49
|
+
* that would make a hostile name safe to pass. A name `shellInertIdentifier` (`sql.ts`) refuses
|
|
50
|
+
* is therefore left out of the command rather than escaped into it: the command still runs and
|
|
51
|
+
* still generates the migration, and the name is read off `cause` and `column`, which are prose
|
|
52
|
+
* nobody pastes.
|
|
53
|
+
*/
|
|
44
54
|
export function unexpectedColumn(table: string, column: string): DriftDifference {
|
|
45
55
|
return {
|
|
46
56
|
kind: 'unexpected-column',
|
|
@@ -48,7 +58,12 @@ export function unexpectedColumn(table: string, column: string): DriftDifference
|
|
|
48
58
|
column,
|
|
49
59
|
// Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
|
|
50
60
|
cause: `table "${table}" has column "${column}" not present in any migration`,
|
|
51
|
-
fix:
|
|
61
|
+
fix:
|
|
62
|
+
shellInertIdentifier(column) === null
|
|
63
|
+
? 'x db gen "add the undeclared column" # the live column name carries a backtick, a ' +
|
|
64
|
+
'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
|
|
65
|
+
'this command'
|
|
66
|
+
: `x db gen "add ${column}"`,
|
|
52
67
|
};
|
|
53
68
|
}
|
|
54
69
|
|
|
@@ -82,6 +97,8 @@ export function changedColumn(
|
|
|
82
97
|
liveNullable: boolean,
|
|
83
98
|
): DriftDifference {
|
|
84
99
|
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
100
|
+
const relation = shellInertIdentifier(table);
|
|
101
|
+
const attribute = shellInertIdentifier(column);
|
|
85
102
|
return {
|
|
86
103
|
kind: 'changed-column',
|
|
87
104
|
table,
|
|
@@ -89,19 +106,47 @@ export function changedColumn(
|
|
|
89
106
|
cause: liveNullable
|
|
90
107
|
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
91
108
|
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
109
|
+
// Both identifiers are the catalog's, so both go through the one screen. A refusal names the
|
|
110
|
+
// column as the thing it could not spell, which is what tells this line apart from
|
|
111
|
+
// `missingCheck`'s refusal in a report that carries both.
|
|
92
112
|
fix:
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
relation === null || attribute === null
|
|
114
|
+
? `${clause} on the column named in this difference, in a new migration, then ` +
|
|
115
|
+
'x db migrate — its table or column name carries a backtick, a dollar sign, a quote, ' +
|
|
116
|
+
'a backslash or whitespace, so no statement here can spell it'
|
|
117
|
+
: `alter table ${relation} alter column ${attribute} ${clause}; # in a new migration` +
|
|
118
|
+
(liveNullable ? ' — backfill the existing NULLs first' : ''),
|
|
95
119
|
};
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The `fix:` names the two edits that actually resolve this, and neither is `x db gen` (issue
|
|
124
|
+
* #345). That command diffs the ENTITY REGISTRY against the newest snapshot, and a table nothing
|
|
125
|
+
* declares is absent from both sides of that diff — so it wrote an EMPTY migration, and the
|
|
126
|
+
* generator's own empty-diff branch writes no file at all, leaving the reader with nothing to run
|
|
127
|
+
* and the same finding on the next deploy.
|
|
128
|
+
*
|
|
129
|
+
* What is left once `@ultimat3/cli`'s `acceptCreatedTables` has run is a table no migration's SQL
|
|
130
|
+
* creates and no entity declares — so either a migration should claim it (`if not exists`, because
|
|
131
|
+
* the relation is already there, and `x db migrate` then accepts a table its own SQL creates), or
|
|
132
|
+
* nothing owns it and it should not be in this schema. No migration PATH is named: where an app
|
|
133
|
+
* keeps its migrations is the CLI's fact, not this package's.
|
|
134
|
+
*/
|
|
98
135
|
export function unexpectedTable(table: string): DriftDifference {
|
|
136
|
+
const name = shellInertIdentifier(table);
|
|
99
137
|
return {
|
|
100
138
|
kind: 'unexpected-table',
|
|
101
139
|
table,
|
|
102
140
|
column: null,
|
|
103
141
|
cause: `table "${table}" is not present in any migration`,
|
|
104
|
-
fix:
|
|
142
|
+
fix:
|
|
143
|
+
name === null
|
|
144
|
+
? 'claim it in a migration with create table if not exists, or drop it by hand — its ' +
|
|
145
|
+
'table name carries a backtick, a dollar sign, a quote, a backslash or whitespace, so ' +
|
|
146
|
+
'no statement here can spell it'
|
|
147
|
+
: `put a create table if not exists ${name} (…) statement in a migration — x db migrate ` +
|
|
148
|
+
'then accepts a table its own SQL creates — or, if nothing owns it, run ' +
|
|
149
|
+
`drop table ${name}; inside psql "$DATABASE_URL"`,
|
|
105
150
|
};
|
|
106
151
|
}
|
|
107
152
|
|
|
@@ -174,6 +219,8 @@ export function changedIndex(table: string, index: string, detail: string): Drif
|
|
|
174
219
|
* the predicate, which is what makes an executable fix possible at all.
|
|
175
220
|
*/
|
|
176
221
|
export function missingCheck(table: string, check: CheckDescription): DriftDifference {
|
|
222
|
+
const relation = shellInertIdentifier(table);
|
|
223
|
+
const constraint = shellInertIdentifier(check.name);
|
|
177
224
|
return {
|
|
178
225
|
kind: 'missing-check',
|
|
179
226
|
table,
|
|
@@ -183,9 +230,19 @@ export function missingCheck(table: string, check: CheckDescription): DriftDiffe
|
|
|
183
230
|
// banned advice word the `errors` gate demands a command beside: writing the migration is half
|
|
184
231
|
// the repair and applying it is the other half, and `changedColumn`'s bare `# in a new
|
|
185
232
|
// migration` leaves the second half to be guessed.
|
|
233
|
+
//
|
|
234
|
+
// Both NAMES go through the one screen; the EXPRESSION deliberately does not, and cannot. It
|
|
235
|
+
// is a predicate, so no screen could accept `status in ('draft', 'published')` and reject a
|
|
236
|
+
// second statement — and it is the DECLARED side's own text, out of the author's migration,
|
|
237
|
+
// where both names are the catalog's and a sidecar's. Narrower than "this line is safe", and
|
|
238
|
+
// it is the honest claim.
|
|
186
239
|
fix:
|
|
187
|
-
|
|
188
|
-
|
|
240
|
+
relation === null || constraint === null
|
|
241
|
+
? 'add the constraint named in this difference back in a new migration, then ' +
|
|
242
|
+
'x db migrate — its table or constraint name carries a backtick, a dollar sign, a ' +
|
|
243
|
+
'quote, a backslash or whitespace, so no statement here can spell it'
|
|
244
|
+
: `alter table ${relation} add constraint ${constraint} ` +
|
|
245
|
+
`check (${check.expression}); # in a new migration, then x db migrate`,
|
|
189
246
|
};
|
|
190
247
|
}
|
|
191
248
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// TEST-ONLY. The two builders every drift suite compares with — a `TableDescription` of text
|
|
2
|
+
// columns and the `SchemaDescription` around it. One copy, because three suites arguing about
|
|
3
|
+
// schemas built differently would each be judging a different fixture. Never exported from
|
|
4
|
+
// `index.ts`.
|
|
5
|
+
|
|
6
|
+
import type { SchemaDescription, TableDescription } from './introspect';
|
|
7
|
+
|
|
8
|
+
export const table = (name: string, columns: readonly string[]): TableDescription => ({
|
|
9
|
+
schema: 'public',
|
|
10
|
+
name,
|
|
11
|
+
columns: columns.map((column, index) => ({
|
|
12
|
+
name: column,
|
|
13
|
+
dataType: 'text',
|
|
14
|
+
nullable: true,
|
|
15
|
+
default: null,
|
|
16
|
+
position: index + 1,
|
|
17
|
+
})),
|
|
18
|
+
primaryKey: ['id'],
|
|
19
|
+
indexes: [],
|
|
20
|
+
foreignKeys: [],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const schema = (...tables: readonly TableDescription[]): SchemaDescription => ({ tables });
|
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,114 +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
|
-
/** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
|
|
308
|
-
export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
309
|
-
new DbError({
|
|
310
|
-
code: 'X_DB_DRIFT',
|
|
311
|
-
cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
|
|
312
|
-
fix: `x db gen "add ${columnName}"`,
|
|
313
|
-
meta: { table: tableName, column: columnName },
|
|
314
|
-
});
|
|
315
|
-
|
|
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
288
|
export const sqlUnsafe = (received: string, position: number): DbError =>
|
|
396
289
|
new DbError({
|
|
397
290
|
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.
|
package/src/generate.ts
CHANGED
|
@@ -9,11 +9,11 @@ import { defaultExpression } from './column-default';
|
|
|
9
9
|
import { isDestructive } from './destructive';
|
|
10
10
|
import { dropOrder } from './drop-order';
|
|
11
11
|
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
12
|
-
import { migrationIrreversible } from './errors';
|
|
13
12
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
14
13
|
import type { Regeneration } from './generated-column';
|
|
15
14
|
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
16
|
-
import { createIndex,
|
|
15
|
+
import { createIndex, impliedByColumnClause } from './index-ddl';
|
|
16
|
+
import { indexPlan } from './index-plan';
|
|
17
17
|
import {
|
|
18
18
|
type ColumnDescription,
|
|
19
19
|
findTable,
|
|
@@ -21,31 +21,14 @@ import {
|
|
|
21
21
|
type TableDescription,
|
|
22
22
|
} from './introspect';
|
|
23
23
|
import { declaredIndexes } from './invariant-ddl';
|
|
24
|
+
import { migrationIrreversible } from './migration-errors';
|
|
24
25
|
import type { MovedAside } from './retype-dependents';
|
|
25
26
|
import { moveDependentsAside } from './retype-dependents';
|
|
27
|
+
import { moveKeysAside, retypedColumns, retypedIn } from './retype-keys';
|
|
26
28
|
import { identifier } from './sql';
|
|
29
|
+
import { sqlType } from './sql-type';
|
|
27
30
|
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
28
31
|
|
|
29
|
-
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
30
|
-
uuid: 'uuid',
|
|
31
|
-
text: 'text',
|
|
32
|
-
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
33
|
-
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
34
|
-
// Without the length no currency ever fits the constraint the same statement demands.
|
|
35
|
-
char: 'char(3)',
|
|
36
|
-
boolean: 'boolean',
|
|
37
|
-
integer: 'integer',
|
|
38
|
-
bigint: 'bigint',
|
|
39
|
-
numeric: 'numeric',
|
|
40
|
-
timestamptz: 'timestamptz',
|
|
41
|
-
date: 'date',
|
|
42
|
-
jsonb: 'jsonb',
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function sqlType(kind: string): string {
|
|
46
|
-
return SQL_TYPES[kind] ?? kind;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
32
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
50
33
|
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
51
34
|
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
@@ -160,15 +143,19 @@ function retypeColumn(
|
|
|
160
143
|
recorded: ColumnDescription,
|
|
161
144
|
plan: Plan,
|
|
162
145
|
moved: MovedAside,
|
|
146
|
+
retyped: ReadonlySet<string>,
|
|
163
147
|
): Regeneration {
|
|
164
148
|
const wanted = sqlType(column.kind);
|
|
165
149
|
const table = live.name;
|
|
166
150
|
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
167
151
|
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
168
152
|
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
169
|
-
return regenerate(
|
|
153
|
+
return regenerate(live, column, wanted, recorded, plan, moved);
|
|
170
154
|
}
|
|
171
|
-
|
|
155
|
+
// The set, never `recorded.dataType === wanted` a second time: `retypedColumns` decided this for
|
|
156
|
+
// the whole schema before any statement was written, because the foreign keys a retype breaks
|
|
157
|
+
// are recorded on tables this diff is not looking at (`retype-keys.ts`).
|
|
158
|
+
if (!retyped.has(column.column)) return 'unchanged';
|
|
172
159
|
moveDependentsAside(live, column.column, plan, moved);
|
|
173
160
|
const alter = (type: string): string =>
|
|
174
161
|
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
@@ -178,7 +165,12 @@ function retypeColumn(
|
|
|
178
165
|
return 'altered';
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
function diffTable(
|
|
168
|
+
function diffTable(
|
|
169
|
+
entity: EntityDescriptionLike,
|
|
170
|
+
live: TableDescription,
|
|
171
|
+
plan: Plan,
|
|
172
|
+
retyped: ReadonlySet<string>,
|
|
173
|
+
): void {
|
|
182
174
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
183
175
|
const added = new Set<string>();
|
|
184
176
|
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
@@ -189,7 +181,7 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
189
181
|
for (const column of entity.columns) {
|
|
190
182
|
const recorded = existing.get(column.column);
|
|
191
183
|
if (recorded !== undefined) {
|
|
192
|
-
if (retypeColumn(live, column, recorded, plan, moved) === 'rebuilt') {
|
|
184
|
+
if (retypeColumn(live, column, recorded, plan, moved, retyped) === 'rebuilt') {
|
|
193
185
|
rebuilt.add(column.column);
|
|
194
186
|
}
|
|
195
187
|
continue;
|
|
@@ -216,23 +208,9 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
216
208
|
);
|
|
217
209
|
}
|
|
218
210
|
|
|
219
|
-
|
|
220
|
-
for
|
|
221
|
-
|
|
222
|
-
// A rebuilt column took its indexes down with it, and a retype dropped the ones whose
|
|
223
|
-
// predicate it could not survive — either way this one is CREATED rather than compared:
|
|
224
|
-
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
225
|
-
const gone = moved.indexes.has(index.name) || index.columns.some((each) => rebuilt.has(each));
|
|
226
|
-
if (recorded !== undefined && !gone) {
|
|
227
|
-
redefineIndex(entity.table, index, recorded, plan);
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
// `added` only: an index over a column that was already there is implied by no clause this
|
|
231
|
-
// migration emits, so it still needs a statement of its own.
|
|
232
|
-
if (impliedByColumnClause(entity, index, added)) continue;
|
|
233
|
-
plan.up.push(createIndex(entity.table, index));
|
|
234
|
-
plan.down.push(dropIndex(index.name));
|
|
235
|
-
}
|
|
211
|
+
// Both directions, in `index-plan.ts`: a recorded index the entity no longer declares is DROPPED
|
|
212
|
+
// there, which is the arm this loop did not have for as long as it lived here.
|
|
213
|
+
indexPlan(entity, live, plan, { added, rebuilt, moved: moved.indexes });
|
|
236
214
|
|
|
237
215
|
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
|
|
238
216
|
// that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
|
|
@@ -292,12 +270,20 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
292
270
|
// Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
|
|
293
271
|
// migration drops makes that `drop table` `2BP01`.
|
|
294
272
|
const preDrops: Plan = { up: [], down: [] };
|
|
273
|
+
// Ahead of EVERYTHING, and at the far end of `down`: a foreign key compiled against a column
|
|
274
|
+
// being retyped has to be gone before the first ALTER and back after the last one, and both ends
|
|
275
|
+
// of one key can move in two different entities' diffs (`retype-keys.ts`).
|
|
276
|
+
const preAlters: Plan = { up: [], down: [] };
|
|
295
277
|
const wanted = new Set(options.entities.map((entity) => entity.table));
|
|
296
278
|
|
|
297
279
|
const doomed = new Set(
|
|
298
280
|
current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
|
|
299
281
|
);
|
|
300
|
-
|
|
282
|
+
// Before the loop, because the answer spans it: `diffTable` is handed one entity's recorded row
|
|
283
|
+
// and the key that a retype of its column breaks is recorded on whichever table OWNS the key.
|
|
284
|
+
const retyped = retypedColumns(options.entities, current);
|
|
285
|
+
const predropped = moveKeysAside(current, retyped, doomed, preAlters);
|
|
286
|
+
const plans: ConstraintPlans = { constraints, preDrops, doomed, predropped };
|
|
301
287
|
|
|
302
288
|
for (const entity of options.entities) {
|
|
303
289
|
const live = findTable(current, entity.table);
|
|
@@ -307,7 +293,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
307
293
|
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
308
294
|
continue;
|
|
309
295
|
}
|
|
310
|
-
diffTable(entity, live, plan);
|
|
296
|
+
diffTable(entity, live, plan, retypedIn(retyped, entity.table));
|
|
311
297
|
const kept = new Set(entity.columns.map((column) => column.column));
|
|
312
298
|
for (const column of live.columns) {
|
|
313
299
|
if (kept.has(column.name)) continue;
|
|
@@ -365,15 +351,17 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
365
351
|
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
|
|
366
352
|
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
|
|
367
353
|
const unrendered = unrenderedOf(options.entities, current);
|
|
368
|
-
const body = plan.up.join('\n');
|
|
354
|
+
const body = [...preAlters.up, ...plan.up].join('\n');
|
|
369
355
|
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
|
|
370
356
|
return {
|
|
371
357
|
id,
|
|
372
358
|
name: options.name,
|
|
373
359
|
fileName: `migrations/${id}.sql`,
|
|
374
360
|
up,
|
|
375
|
-
// Reverse order: the last thing created is the first thing dropped.
|
|
376
|
-
|
|
361
|
+
// Reverse order: the last thing created is the first thing dropped. `preAlters` goes in at the
|
|
362
|
+
// FRONT here precisely so reversal puts it last — a key is added back only once both of its
|
|
363
|
+
// ends have been retyped back, which is every other statement in the script.
|
|
364
|
+
down: [...preAlters.down, ...plan.down].reverse().join('\n'),
|
|
377
365
|
snapshot: snapshotOf(options.entities),
|
|
378
366
|
destructive: isDestructive(up),
|
|
379
367
|
unrendered,
|