@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: which indexes an existing table gains, has rebuilt, or LOSES. Split out
|
|
2
|
+
// of `generate.ts` along the seam `check-ddl.ts` and `index-ddl.ts` already drew — `generate.ts`
|
|
3
|
+
// assembles a plan, `index-ddl.ts` writes the statements, and this file decides which of them go
|
|
4
|
+
// in. `checkPlan` is its shape, deliberately: "what does the record hold that the declaration does
|
|
5
|
+
// not" is one question, and a fourth spelling of it is the split axiom 1 refuses.
|
|
6
|
+
//
|
|
7
|
+
// The removal arm is why this file exists. `diffTable` walked `declaredIndexes(entity)` and matched
|
|
8
|
+
// by name, with no reverse pass, so an index the entities stopped declaring stayed on the database
|
|
9
|
+
// forever while the sidecar beside it stopped recording it — `examples/dummy` carried
|
|
10
|
+
// `member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` through every
|
|
11
|
+
// regeneration, and `x verify`'s drift step was green over all three because drift judges the
|
|
12
|
+
// declared side. The same defect `foreignKeyPlan` closed on 2026-08-19, one arm over.
|
|
13
|
+
//
|
|
14
|
+
// KNOWN LIMIT, named rather than half-built: a UNIQUE index that a foreign key on ANOTHER table
|
|
15
|
+
// still references cannot be dropped (2BP01), and this arm sees one table at a time. That
|
|
16
|
+
// declaration is already broken — the key has nothing to point at — and the failure arrives with
|
|
17
|
+
// the server's own words naming both ends.
|
|
18
|
+
|
|
19
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
20
|
+
import type { Plan } from './foreign-key-plan';
|
|
21
|
+
import {
|
|
22
|
+
asDeclared,
|
|
23
|
+
createIndex,
|
|
24
|
+
dropIndex,
|
|
25
|
+
dropRecordedIndex,
|
|
26
|
+
impliedByColumnClause,
|
|
27
|
+
redefineIndex,
|
|
28
|
+
} from './index-ddl';
|
|
29
|
+
import type { TableDescription } from './introspect';
|
|
30
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the rest of this migration has already done to the columns underneath the indexes — every
|
|
34
|
+
* field is a set of names some other arm produced, and each answers "this index is already gone".
|
|
35
|
+
*/
|
|
36
|
+
export interface IndexPlanContext {
|
|
37
|
+
/** Columns this migration ADDS, whose own `unique` clause brings an index Postgres names. */
|
|
38
|
+
readonly added: ReadonlySet<string>;
|
|
39
|
+
/** Columns `regenerate` dropped and re-added outright — every index over one went with it. */
|
|
40
|
+
readonly rebuilt: ReadonlySet<string>;
|
|
41
|
+
/** Indexes a retype already dropped ahead of its ALTER (`moveDependentsAside`). */
|
|
42
|
+
readonly moved: ReadonlySet<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which indexes an existing table gains, has rebuilt, or loses.
|
|
47
|
+
*
|
|
48
|
+
* Declared first and removed last, the order `checkPlan` uses. Both orders are safe — two indexes
|
|
49
|
+
* over the same columns may coexist for the length of one migration — so the tie goes to the file
|
|
50
|
+
* this one is a copy of.
|
|
51
|
+
*
|
|
52
|
+
* `down` is pushed FORWARDS and read backwards, because assembly reverses it: the restore of a
|
|
53
|
+
* removed index therefore lands after the drop of everything created beside it.
|
|
54
|
+
*/
|
|
55
|
+
export function indexPlan(
|
|
56
|
+
entity: EntityDescriptionLike,
|
|
57
|
+
live: TableDescription,
|
|
58
|
+
plan: Plan,
|
|
59
|
+
context: IndexPlanContext,
|
|
60
|
+
): void {
|
|
61
|
+
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
62
|
+
const declared = new Set<string>();
|
|
63
|
+
for (const index of declaredIndexes(entity)) {
|
|
64
|
+
declared.add(index.name);
|
|
65
|
+
const recorded = indexed.get(index.name);
|
|
66
|
+
// A rebuilt column took its indexes down with it, and a retype dropped the ones whose
|
|
67
|
+
// predicate it could not survive — either way this one is CREATED rather than compared:
|
|
68
|
+
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
69
|
+
const gone =
|
|
70
|
+
context.moved.has(index.name) || index.columns.some((each) => context.rebuilt.has(each));
|
|
71
|
+
if (recorded !== undefined && !gone) {
|
|
72
|
+
redefineIndex(entity.table, index, recorded, plan);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// `added` only: an index over a column that was already there is implied by no clause this
|
|
76
|
+
// migration emits, so it still needs a statement of its own.
|
|
77
|
+
if (impliedByColumnClause(entity, index, context.added)) continue;
|
|
78
|
+
plan.up.push(createIndex(entity.table, index));
|
|
79
|
+
// The plain drop, always: this migration CREATED it, with `create index`, so it is an index
|
|
80
|
+
// and never a constraint's — `dropRecordedIndex`'s ambiguity is about the recorded side only.
|
|
81
|
+
plan.down.push(dropIndex(index.name));
|
|
82
|
+
}
|
|
83
|
+
removeUndeclared(entity, live, plan, context, declared);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every recorded index this entity no longer declares, dropped — and restored in `down` from what
|
|
88
|
+
* the SNAPSHOT recorded, never from what the entity declares, since the entity is precisely what
|
|
89
|
+
* stopped describing it. The rule the retype path already states.
|
|
90
|
+
*
|
|
91
|
+
* Four names are skipped, and each one is a statement Postgres would refuse or repeat:
|
|
92
|
+
*
|
|
93
|
+
* | skipped | because |
|
|
94
|
+
* |------------------------------------------|---------|
|
|
95
|
+
* | `primary` | `drop index` on a primary key's index is 2BP01; the key is `TableDescription.primaryKey`, a different question |
|
|
96
|
+
* | already in `context.moved` | a retype dropped it ahead of the ALTER — a second drop is 42704 |
|
|
97
|
+
* | over a column in `context.rebuilt` | it went with the `drop column` half of `regenerate` — 42704 |
|
|
98
|
+
* | over a column this migration DROPS | `alter table … drop column` takes it, so a drop beside it says nothing new. The rule `foreignKeyPlan` applies to a constraint on a dropped column |
|
|
99
|
+
*
|
|
100
|
+
* A doomed TABLE needs no arm: `generate.ts` only reaches a diff for a table an entity still
|
|
101
|
+
* declares, so `drop table` and this function never meet.
|
|
102
|
+
*/
|
|
103
|
+
function removeUndeclared(
|
|
104
|
+
entity: EntityDescriptionLike,
|
|
105
|
+
live: TableDescription,
|
|
106
|
+
plan: Plan,
|
|
107
|
+
context: IndexPlanContext,
|
|
108
|
+
declared: ReadonlySet<string>,
|
|
109
|
+
): void {
|
|
110
|
+
const columns = new Set(entity.columns.map((column) => column.column));
|
|
111
|
+
for (const recorded of live.indexes) {
|
|
112
|
+
if (recorded.primary || declared.has(recorded.name)) continue;
|
|
113
|
+
if (context.moved.has(recorded.name)) continue;
|
|
114
|
+
if (recorded.columns.some((column) => context.rebuilt.has(column))) continue;
|
|
115
|
+
if (!recorded.columns.every((column) => columns.has(column))) continue;
|
|
116
|
+
plan.up.push(...dropRecordedIndex(live.name, recorded));
|
|
117
|
+
plan.down.push(createIndex(live.name, asDeclared(recorded)));
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -81,15 +81,9 @@ export {
|
|
|
81
81
|
dbUnavailable,
|
|
82
82
|
driverError,
|
|
83
83
|
identifierUnsafe,
|
|
84
|
-
migrateConcurrent,
|
|
85
|
-
migrationConflict,
|
|
86
|
-
migrationDestructive,
|
|
87
|
-
migrationIrreversible,
|
|
88
|
-
migrationSnapshotMissing,
|
|
89
84
|
multipleStatements,
|
|
90
85
|
poolAcquireTimeout,
|
|
91
86
|
poolMaxInvalid,
|
|
92
|
-
rollbackStepsInvalid,
|
|
93
87
|
serializationExhausted,
|
|
94
88
|
sqlUnsafe,
|
|
95
89
|
} from './errors';
|
|
@@ -147,6 +141,15 @@ export {
|
|
|
147
141
|
rollback,
|
|
148
142
|
runningAppVersion,
|
|
149
143
|
} from './migrate';
|
|
144
|
+
export {
|
|
145
|
+
migrateConcurrent,
|
|
146
|
+
migrationConflict,
|
|
147
|
+
migrationDestructive,
|
|
148
|
+
migrationIrreversible,
|
|
149
|
+
migrationSnapshotMissing,
|
|
150
|
+
migrationViewDepends,
|
|
151
|
+
rollbackStepsInvalid,
|
|
152
|
+
} from './migration-errors';
|
|
150
153
|
export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
|
|
151
154
|
export { setStatementObserver, statementObserver } from './observe';
|
|
152
155
|
export type {
|
package/src/introspect.ts
CHANGED
|
@@ -87,6 +87,24 @@ export interface TableDescription {
|
|
|
87
87
|
* would leave every already-generated app's invariants unenforced forever.
|
|
88
88
|
*/
|
|
89
89
|
readonly checks?: readonly CheckDescription[] | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The catalog's half of `checks`, and a **separate field rather than the same one** — names
|
|
92
|
+
* only, `conname` for `contype = 'c'`, never a definition.
|
|
93
|
+
*
|
|
94
|
+
* The two readings cannot share `checks` because they are not the same value. A catalog read
|
|
95
|
+
* carries Postgres' own rewriting of the predicate and a declaration carries this generator's
|
|
96
|
+
* spelling, so a `checks` filled from `pg_constraint` would put a rewritten expression on the
|
|
97
|
+
* field `checkPlan` diffs against a generated one — every regenerated migration would then drop
|
|
98
|
+
* and re-add every constraint in the app, forever, because the two strings can never be equal.
|
|
99
|
+
* Splitting them means the type says which reading a value came from, and `checkPlan` cannot be
|
|
100
|
+
* handed a catalog value by accident.
|
|
101
|
+
*
|
|
102
|
+
* `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
|
|
103
|
+
* alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody
|
|
104
|
+
* asked the catalog", which is what keeps `compareChecks` silent on a description that never
|
|
105
|
+
* read one instead of reporting every declared constraint as missing.
|
|
106
|
+
*/
|
|
107
|
+
readonly checkNames?: readonly string[] | undefined;
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
export interface SchemaDescription {
|
|
@@ -135,6 +153,12 @@ interface ForeignKeyRow {
|
|
|
135
153
|
readonly on_delete: string | null;
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
/** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */
|
|
157
|
+
interface CheckRow {
|
|
158
|
+
readonly table_name: string;
|
|
159
|
+
readonly constraint_name: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
|
|
139
163
|
|
|
140
164
|
export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
|
|
@@ -206,7 +230,21 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
206
230
|
order by src.relname, c.conname
|
|
207
231
|
`);
|
|
208
232
|
|
|
209
|
-
|
|
233
|
+
// `conname` and nothing else. `pg_get_constraintdef(c.oid)` is one word further along this line
|
|
234
|
+
// and is the reason this query did not exist: it answers Postgres' rewriting of the predicate,
|
|
235
|
+
// which no generated spelling can ever equal, so reading it would make every drift check report
|
|
236
|
+
// a correct database as wrong. `contype = 'c'` is the CHECK constraints alone — Postgres 17
|
|
237
|
+
// onwards records a NOT NULL as `'n'`, and a domain's as `'c'` on the domain rather than here.
|
|
238
|
+
const checks = await client.query<CheckRow>(sql`
|
|
239
|
+
select src.relname as table_name, c.conname as constraint_name
|
|
240
|
+
from pg_constraint c
|
|
241
|
+
join pg_class src on src.oid = c.conrelid
|
|
242
|
+
join pg_namespace n on n.oid = src.relnamespace
|
|
243
|
+
where c.contype = 'c' and n.nspname = ${schema} and src.relkind = 'r'
|
|
244
|
+
order by src.relname, c.conname
|
|
245
|
+
`);
|
|
246
|
+
|
|
247
|
+
return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks);
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
/** Pure, so the row -> description mapping is testable without a database. */
|
|
@@ -216,6 +254,7 @@ export function buildSchema(
|
|
|
216
254
|
columns: readonly ColumnRow[],
|
|
217
255
|
indexes: readonly IndexRow[],
|
|
218
256
|
foreignKeys: readonly ForeignKeyRow[],
|
|
257
|
+
checks: readonly CheckRow[] = [],
|
|
219
258
|
): SchemaDescription {
|
|
220
259
|
const names = [...new Set(columns.map((row) => row.table_name))]
|
|
221
260
|
.filter((name) => !excluded.includes(name))
|
|
@@ -261,6 +300,12 @@ export function buildSchema(
|
|
|
261
300
|
onDelete: row.on_delete,
|
|
262
301
|
}))
|
|
263
302
|
.sort(byName),
|
|
303
|
+
// Always written, `[]` included: this reading of a table HAS asked the catalog, and absence
|
|
304
|
+
// is reserved for a description that has not (`compareChecks` is silent on that one).
|
|
305
|
+
checkNames: checks
|
|
306
|
+
.filter((row) => row.table_name === name)
|
|
307
|
+
.map((row) => row.constraint_name)
|
|
308
|
+
.sort(),
|
|
264
309
|
};
|
|
265
310
|
});
|
|
266
311
|
|
package/src/migrate.ts
CHANGED
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
isReservable,
|
|
12
12
|
poolProfileFor,
|
|
13
13
|
} from './client';
|
|
14
|
-
import {
|
|
14
|
+
import { refuseDependentViews } from './dependent-view';
|
|
15
15
|
import { expectedQueryLoop } from './expected-loop';
|
|
16
16
|
import type { SchemaDescription } from './introspect';
|
|
17
|
+
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
|
|
17
18
|
import { raw, sql } from './sql';
|
|
18
19
|
import { SQLSTATE, sqlState } from './sqlstate';
|
|
19
20
|
import { statementsOf } from './statement-split';
|
|
@@ -348,6 +349,12 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
348
349
|
await withTransaction(
|
|
349
350
|
async (tx) => {
|
|
350
351
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
352
|
+
// Before the first statement, never after the failure: a view compiled against a
|
|
353
|
+
// column this script retypes is `0A000` from the server with the view named only in
|
|
354
|
+
// a DETAIL field nothing prints, surfaced as "cannot reach the database". Costs one
|
|
355
|
+
// text scan and no round trip on a migration that retypes nothing, which is nearly
|
|
356
|
+
// all of them (`dependent-view.ts`).
|
|
357
|
+
await refuseDependentViews(tx, migration.up);
|
|
351
358
|
await applyScript(tx, migration.up);
|
|
352
359
|
const durationMs = Math.round(performance.now() - at);
|
|
353
360
|
await tx.execute(sql`
|
|
@@ -438,6 +445,9 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
438
445
|
await withTransaction(
|
|
439
446
|
async (tx) => {
|
|
440
447
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
448
|
+
// Both directions: a reversal retypes the same column back, and a view created
|
|
449
|
+
// since the migration applied blocks it exactly as one created before would.
|
|
450
|
+
await refuseDependentViews(tx, migration.down);
|
|
441
451
|
await applyScript(tx, migration.down);
|
|
442
452
|
await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
443
453
|
},
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
|
|
2
|
+
// disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
|
|
3
|
+
// saying so, a snapshot that was never written, and a view standing in a retype's way. Split out of
|
|
4
|
+
// `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
|
|
5
|
+
// was: every code below is still declared, titled and registered there, and `DbError` is still the
|
|
6
|
+
// one class. One direction only — nothing here is imported back.
|
|
7
|
+
|
|
8
|
+
import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
|
|
9
|
+
import { DbError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
|
|
13
|
+
* no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
|
|
14
|
+
* left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
|
|
15
|
+
* failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
|
|
16
|
+
* exit code.
|
|
17
|
+
*/
|
|
18
|
+
export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
|
|
19
|
+
new DbError({
|
|
20
|
+
code: 'X_MIGRATE_CONCURRENT',
|
|
21
|
+
cause:
|
|
22
|
+
`another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
|
|
23
|
+
'so this migrator refused rather than block a deploy forever',
|
|
24
|
+
fix:
|
|
25
|
+
'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
|
|
26
|
+
"join pg_locks using (pid) where locktype = 'advisory'\"" +
|
|
27
|
+
' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
|
|
28
|
+
meta: { lockKey, waitedMs },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
32
|
+
new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
|
|
33
|
+
|
|
34
|
+
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
35
|
+
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A view standing in the way of a retype, refused one statement before Postgres would have.
|
|
39
|
+
*
|
|
40
|
+
* `restore` is the caller's, the way `migrationIrreversible`'s `fix:` is: it is a pair of
|
|
41
|
+
* `psql "$DATABASE_URL" -c '…'` invocations — the shape `migrationConflict` above already writes —
|
|
42
|
+
* built out of live catalog values through `identifier()`. This file may not import `sql.ts`: that
|
|
43
|
+
* module imports `identifierUnsafe` from here, and an import cycle around the module whose
|
|
44
|
+
* evaluation REGISTERS every code is not a cycle worth having for one quoted name.
|
|
45
|
+
*/
|
|
46
|
+
export const migrationViewDepends = (
|
|
47
|
+
view: string,
|
|
48
|
+
table: string,
|
|
49
|
+
column: string,
|
|
50
|
+
restore: string,
|
|
51
|
+
): DbError =>
|
|
52
|
+
new DbError({
|
|
53
|
+
code: 'X_MIGRATION_VIEW_DEPENDS',
|
|
54
|
+
cause:
|
|
55
|
+
`view "${view}" is compiled against "${table}"."${column}", which this migration retypes; ` +
|
|
56
|
+
'Postgres answers 0A000 and rolls the whole migration back',
|
|
57
|
+
fix: restore,
|
|
58
|
+
meta: { view, table, column },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
|
|
63
|
+
* negative count counts from the END: `steps: -1` selected every applied migration except the
|
|
64
|
+
* newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
|
|
65
|
+
* Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
|
|
66
|
+
* as a different one is the failure a validated argument exists to prevent.
|
|
67
|
+
*/
|
|
68
|
+
export const rollbackStepsInvalid = (received: number): DbError =>
|
|
69
|
+
new DbError({
|
|
70
|
+
code: 'X_INVARIANT',
|
|
71
|
+
cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
|
|
72
|
+
fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
|
|
73
|
+
meta: { steps: received },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
78
|
+
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
|
79
|
+
* rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
|
|
80
|
+
* keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
|
|
81
|
+
*/
|
|
82
|
+
const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
|
|
86
|
+
* and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
|
|
87
|
+
* the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
|
|
88
|
+
*/
|
|
89
|
+
const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The sidecar every generated migration writes is what the *next* generation diffs against, so a
|
|
93
|
+
* newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
|
|
94
|
+
* schema, which would generate `create table` for every table the database already holds.
|
|
95
|
+
*/
|
|
96
|
+
export const migrationSnapshotMissing = (id: string, file: string): DbError =>
|
|
97
|
+
new DbError({
|
|
98
|
+
code: 'X_MIGRATION_SNAPSHOT_MISSING',
|
|
99
|
+
cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
|
|
100
|
+
// Two remedies, both commands, in the order they are safe to try. "restore from version
|
|
101
|
+
// control" alone was neither: on a scaffolded app the sidecar was never written, so there is
|
|
102
|
+
// nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
|
|
103
|
+
// the two errors pointed at each other and an app's first migration had no way out.
|
|
104
|
+
// `x db gen` is named only *after* the files it would trip over are gone.
|
|
105
|
+
fix:
|
|
106
|
+
`git checkout -- ${file} # or, if it was never written: ` +
|
|
107
|
+
`rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
|
|
108
|
+
meta: { id, file },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One error per file, never one per statement: the marker declares the whole migration, so a
|
|
113
|
+
* second finding would repeat an instruction the first already gave. `file` is app-relative and
|
|
114
|
+
* arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
|
|
115
|
+
*
|
|
116
|
+
* Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
|
|
117
|
+
* a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
|
|
118
|
+
* destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
|
|
119
|
+
*/
|
|
120
|
+
export const migrationDestructive = (
|
|
121
|
+
file: string,
|
|
122
|
+
first: DestructiveStatement,
|
|
123
|
+
more = 0,
|
|
124
|
+
): DbError =>
|
|
125
|
+
new DbError({
|
|
126
|
+
code: 'X_MIGRATION_DESTRUCTIVE',
|
|
127
|
+
cause:
|
|
128
|
+
`${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
|
|
129
|
+
`${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
|
|
130
|
+
fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
|
|
131
|
+
meta: { file, kind: first.kind, statements: more + 1 },
|
|
132
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Single responsibility: which RECORDED objects a retype of one column breaks, and the statements
|
|
2
|
+
// that take them out of the way before the ALTER and put them back in the `down`.
|
|
3
|
+
//
|
|
4
|
+
// Postgres compiles a partial index's predicate and a CHECK's expression against the column's type
|
|
5
|
+
// at creation and cannot recompile either: `alter table "posts" alter column "status" type text
|
|
6
|
+
// using "status"::text` answers `42883 operator does not exist: text = post_status` and the
|
|
7
|
+
// migration aborts mid-run — inside `ROLE=migrate`, with the ledger recording nothing. Measured on
|
|
8
|
+
// Postgres 18.4 (`generate-retype.live.test.ts`), one dependent shape at a time:
|
|
9
|
+
//
|
|
10
|
+
// | recorded object | survives the ALTER |
|
|
11
|
+
// |------------------------------------------|--------------------|
|
|
12
|
+
// | btree on the column, plain or unique | yes — Postgres rebuilds it itself |
|
|
13
|
+
// | composite btree including the column | yes |
|
|
14
|
+
// | partial index whose predicate names it | **no — 42883** |
|
|
15
|
+
// | partial index naming another column | yes |
|
|
16
|
+
// | CHECK whose expression names it | **no — 42883** |
|
|
17
|
+
//
|
|
18
|
+
// So only an expression that MENTIONS the column is dependent, and dropping the rest would be a
|
|
19
|
+
// table scan per index for nothing.
|
|
20
|
+
|
|
21
|
+
import { addCheck, dropCheck } from './check-ddl';
|
|
22
|
+
import type { Plan } from './foreign-key-plan';
|
|
23
|
+
import { asDeclared, createIndex, dropIndex } from './index-ddl';
|
|
24
|
+
import type { CheckDescription, IndexDescription, TableDescription } from './introspect';
|
|
25
|
+
import { IDENTIFIER_PART, noiseAt } from './sql-scan';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether `expression` reads `column`, over-approximating on purpose.
|
|
29
|
+
*
|
|
30
|
+
* The two errors are not symmetrical. A dependent object missed is `42883` in the release phase;
|
|
31
|
+
* one reported that was not is a rebuild nobody asked for — so every ambiguous case answers `true`,
|
|
32
|
+
* and the folding is case-insensitive because Postgres folds an unquoted identifier to lower case
|
|
33
|
+
* and `"Status"` naming a different column is a rarity beside a predicate this must not miss.
|
|
34
|
+
*
|
|
35
|
+
* What it does NOT count is noise, through this package's one lexer (`sql-scan.ts`): the `status`
|
|
36
|
+
* in `where kind = 'status'` is data, not a reference, and the one in `-- status` is prose. A
|
|
37
|
+
* QUOTED identifier is counted — `"status"` is the reference the catalog stores for an author who
|
|
38
|
+
* quoted it, and skipping it as noise is exactly the miss that ends in `42883`.
|
|
39
|
+
*/
|
|
40
|
+
export function referencesColumn(expression: string, column: string): boolean {
|
|
41
|
+
const wanted = column.toLowerCase();
|
|
42
|
+
let at = 0;
|
|
43
|
+
while (at < expression.length) {
|
|
44
|
+
const noise = noiseAt(expression, at);
|
|
45
|
+
if (noise !== null) {
|
|
46
|
+
if (
|
|
47
|
+
noise.kind === 'identifier' &&
|
|
48
|
+
expression.slice(at + 1, noise.end - 1).toLowerCase() === wanted
|
|
49
|
+
) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
at = noise.end;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!IDENTIFIER_PART.test(expression[at] ?? '')) {
|
|
56
|
+
at += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let end = at;
|
|
60
|
+
while (end < expression.length && IDENTIFIER_PART.test(expression[end] ?? '')) end += 1;
|
|
61
|
+
if (expression.slice(at, end).toLowerCase() === wanted) return true;
|
|
62
|
+
at = end;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The recorded objects a retype of `column` cannot leave in place. */
|
|
68
|
+
export interface RetypeDependents {
|
|
69
|
+
/**
|
|
70
|
+
* Partial indexes whose predicate reads the column. A `primary` one is structurally impossible —
|
|
71
|
+
* a primary key index has no predicate — which is what keeps `drop index` off the two indexes
|
|
72
|
+
* Postgres refuses it on: a primary key's and a unique constraint's.
|
|
73
|
+
*/
|
|
74
|
+
readonly indexes: readonly IndexDescription[];
|
|
75
|
+
/** CHECK constraints whose expression reads the column. */
|
|
76
|
+
readonly checks: readonly CheckDescription[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a retype of `table`.`column` breaks, read off the RECORDED schema — never the catalog.
|
|
81
|
+
* `x db gen` runs with no database open, so a hand-added expression index over the same column is
|
|
82
|
+
* invisible here and still `42883`; what this can see is every object a migration wrote down.
|
|
83
|
+
*/
|
|
84
|
+
export function retypeDependents(column: string, live: TableDescription): RetypeDependents {
|
|
85
|
+
return {
|
|
86
|
+
indexes: live.indexes.filter(
|
|
87
|
+
(index) => !index.primary && index.where !== null && referencesColumn(index.where, column),
|
|
88
|
+
),
|
|
89
|
+
checks: (live.checks ?? []).filter((check) => referencesColumn(check.expression, column)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What this plan has already dropped ahead of a retype — names only, because that is all the two
|
|
95
|
+
* readers need. The ordinary diff runs AFTER the ALTER and must not act on an object that is no
|
|
96
|
+
* longer there: the index loop CREATES a name in `indexes` instead of comparing it (a `drop index`
|
|
97
|
+
* on a name already dropped is `42704`, and a definition that never moved would emit nothing at
|
|
98
|
+
* all, leaving the table with no index), and `checkPlan` neither drops nor re-adds a name in
|
|
99
|
+
* `checks` — the declared side is added back by its own arm, and a recorded constraint the entity
|
|
100
|
+
* no longer declares is simply gone, which is what `checkPlan` would have done to it anyway.
|
|
101
|
+
*/
|
|
102
|
+
export interface MovedAside {
|
|
103
|
+
readonly indexes: Set<string>;
|
|
104
|
+
readonly checks: Set<string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop every dependent in `up`, restore it in `down`, and record what was moved.
|
|
109
|
+
*
|
|
110
|
+
* `down` is reversed at assembly, so the restores are pushed FORWARDS here and the retype's own
|
|
111
|
+
* reversal is pushed after them — the reversed script therefore reads: retype back to the old
|
|
112
|
+
* type, then recreate the objects that were compiled against it. Restoring first would recreate a
|
|
113
|
+
* predicate against a type the column no longer has, which is `42883` in the other direction.
|
|
114
|
+
*
|
|
115
|
+
* What is restored is what the snapshot RECORDED, never what the entity declares: an object still
|
|
116
|
+
* declared is re-created by the ordinary diff, one statement later, in its current shape.
|
|
117
|
+
*/
|
|
118
|
+
export function moveDependentsAside(
|
|
119
|
+
live: TableDescription,
|
|
120
|
+
column: string,
|
|
121
|
+
plan: Plan,
|
|
122
|
+
moved: MovedAside,
|
|
123
|
+
): void {
|
|
124
|
+
const dependents = retypeDependents(column, live);
|
|
125
|
+
for (const index of dependents.indexes) {
|
|
126
|
+
plan.up.push(dropIndex(index.name));
|
|
127
|
+
plan.down.push(createIndex(live.name, asDeclared(index)));
|
|
128
|
+
moved.indexes.add(index.name);
|
|
129
|
+
}
|
|
130
|
+
for (const check of dependents.checks) {
|
|
131
|
+
plan.up.push(dropCheck(live.name, check.name));
|
|
132
|
+
plan.down.push(addCheck(live.name, check));
|
|
133
|
+
moved.checks.add(check.name);
|
|
134
|
+
}
|
|
135
|
+
}
|