@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,139 @@
|
|
|
1
|
+
// Single responsibility: which columns this migration RETYPES, and which recorded FOREIGN KEYS
|
|
2
|
+
// that breaks — the one answer to both, computed over the whole schema and above `diffTable`.
|
|
3
|
+
//
|
|
4
|
+
// **Why above `diffTable`, and not inside it like every other dependent.** Postgres re-checks a
|
|
5
|
+
// key's two ends against each other on every `alter column … type` and cannot rebuild one whose
|
|
6
|
+
// sides stopped matching: measured on 18.4, `42804 foreign key constraint "rk_posts_org_code_fkey"
|
|
7
|
+
// cannot be implemented — Key columns "org_code" … and "code" … are of incompatible types: integer
|
|
8
|
+
// and text`, thrown by the ALTER itself, inside `ROLE=migrate`, with the ledger recording nothing.
|
|
9
|
+
// The constraint that breaks is recorded on the table that OWNS it, which for a retype of the
|
|
10
|
+
// key's TARGET is a different entity's record — `diffTable(orgs)` is handed `orgs`'s row and can
|
|
11
|
+
// never see `posts.foreignKeys`. So the retype set is derived once, from `entities` and `current`
|
|
12
|
+
// together, and `retypeColumn` READS it rather than deciding again: two answers to "is this column
|
|
13
|
+
// being retyped" is the axiom-1 split this package has spent the week closing.
|
|
14
|
+
//
|
|
15
|
+
// **Over-approximated on purpose, the rule `retype-dependents.ts` states.** Whether two types keep
|
|
16
|
+
// an equality operator between them is operator resolution, which is exactly the knowledge a
|
|
17
|
+
// generator with no database cannot have — `varchar(80)` and `text` share one, `integer` and
|
|
18
|
+
// `text` do not. A key moved aside that did not need to be is one `add constraint` re-validating a
|
|
19
|
+
// table the ALTER beside it is already rewriting under ACCESS EXCLUSIVE; a key missed is the
|
|
20
|
+
// release phase failing with the server's words and none of the entity's.
|
|
21
|
+
//
|
|
22
|
+
// **What it cannot see.** A key the recorded schema does not hold — a hand-written migration's,
|
|
23
|
+
// or one from a sidecar written before `foreignKeys` was recorded — is invisible here and still
|
|
24
|
+
// `42804`, the same construction limit `x db gen` has against a hand-added expression index. And
|
|
25
|
+
// re-adding the key is still the SERVER's judgement: an entity that retypes one end and not the
|
|
26
|
+
// other declares a pairing Postgres has no operator for, and the `add constraint` at the end of
|
|
27
|
+
// `up` is where that is said. Refusing it here would need the type knowledge two paragraphs up.
|
|
28
|
+
|
|
29
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
30
|
+
import { addForeignKey, dropForeignKey, keyId, unrestorableNote } from './foreign-key';
|
|
31
|
+
import type { Plan } from './foreign-key-plan';
|
|
32
|
+
import { isGenerated } from './generated-column';
|
|
33
|
+
import type { ForeignKeyDescription, SchemaDescription, TableDescription } from './introspect';
|
|
34
|
+
import { findTable } from './introspect';
|
|
35
|
+
import { sqlType } from './sql-type';
|
|
36
|
+
|
|
37
|
+
/** Table name to the columns whose physical type this migration moves. Empty entries are omitted. */
|
|
38
|
+
export type RetypedColumns = ReadonlyMap<string, ReadonlySet<string>>;
|
|
39
|
+
|
|
40
|
+
/** The columns of one table this migration retypes — `retypeColumn`'s own read of the set above. */
|
|
41
|
+
export function retypedIn(retyped: RetypedColumns, table: string): ReadonlySet<string> {
|
|
42
|
+
return retyped.get(table) ?? new Set<string>();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every plain `alter column … type` this migration will emit, before any of them is written.
|
|
47
|
+
*
|
|
48
|
+
* A GENERATED column is deliberately absent: `generated-column.ts` owns every statement one of
|
|
49
|
+
* them produces, and its plain -> generated path is a `drop column` that takes the key with it
|
|
50
|
+
* rather than an ALTER that trips over it. That gap is real and is named in `generated-column.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export function retypedColumns(
|
|
53
|
+
entities: readonly EntityDescriptionLike[],
|
|
54
|
+
current: SchemaDescription,
|
|
55
|
+
): RetypedColumns {
|
|
56
|
+
const moved = new Map<string, ReadonlySet<string>>();
|
|
57
|
+
for (const entity of entities) {
|
|
58
|
+
const live = findTable(current, entity.table);
|
|
59
|
+
if (live === undefined) continue;
|
|
60
|
+
const recorded = new Map(live.columns.map((column) => [column.name, column]));
|
|
61
|
+
const columns = new Set<string>();
|
|
62
|
+
for (const column of entity.columns) {
|
|
63
|
+
const held = recorded.get(column.column);
|
|
64
|
+
if (held === undefined) continue;
|
|
65
|
+
if (isGenerated(column) || held.generated !== undefined) continue;
|
|
66
|
+
if (held.dataType !== sqlType(column.kind)) columns.add(column.column);
|
|
67
|
+
}
|
|
68
|
+
if (columns.size > 0) moved.set(entity.table, columns);
|
|
69
|
+
}
|
|
70
|
+
return moved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether either end of `key` sits on a column this migration retypes. `owner` owns the key. */
|
|
74
|
+
function breaksOn(key: ForeignKeyDescription, owner: string, retyped: RetypedColumns): boolean {
|
|
75
|
+
const own = retyped.get(owner);
|
|
76
|
+
if (own !== undefined && key.columns.some((column) => own.has(column))) return true;
|
|
77
|
+
const target = retyped.get(key.referencedTable);
|
|
78
|
+
return target !== undefined && key.referencedColumns.some((column) => target.has(column));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drop every recorded key a retype breaks, restore it in `down`, and answer which names were moved.
|
|
83
|
+
*
|
|
84
|
+
* The two statements go in the plan's OWN buckets and not beside the ALTER, because the drop has
|
|
85
|
+
* to precede every alter in the migration and the restore has to follow every one of them — both
|
|
86
|
+
* ends of a key can move, in two different entities' diffs. `preAlters` is merged at the very top
|
|
87
|
+
* of `up` and at the very FRONT of `down`, which reversal turns into the very end: so the reversed
|
|
88
|
+
* script reads drop-the-new-key, retype both ends back, add the recorded key. Restoring it any
|
|
89
|
+
* earlier is `42804` in the other direction.
|
|
90
|
+
*
|
|
91
|
+
* What comes back in `up` is not written here at all: `foreignKeyPlan` reads the returned set,
|
|
92
|
+
* treats a moved key as one the schema does not record, and adds the DECLARED key in the
|
|
93
|
+
* `constraints` bucket that already runs after every table statement. That is what makes the three
|
|
94
|
+
* outcomes fall out of code that already exists — still declared (added back), no longer declared
|
|
95
|
+
* (gone, exactly as the removal arm would have left it), and declared with a new `on delete` rule
|
|
96
|
+
* (added back carrying it) — instead of three branches restating them here.
|
|
97
|
+
*/
|
|
98
|
+
export function moveKeysAside(
|
|
99
|
+
current: SchemaDescription,
|
|
100
|
+
retyped: RetypedColumns,
|
|
101
|
+
doomed: ReadonlySet<string>,
|
|
102
|
+
preAlters: Plan,
|
|
103
|
+
): ReadonlySet<string> {
|
|
104
|
+
const moved = new Set<string>();
|
|
105
|
+
for (const table of current.tables) {
|
|
106
|
+
for (const key of table.foreignKeys) {
|
|
107
|
+
if (!breaksOn(key, table.name, retyped)) continue;
|
|
108
|
+
moved.add(keyId(table.name, key.name));
|
|
109
|
+
preAlters.up.push(dropForeignKey(table.name, key.name));
|
|
110
|
+
preAlters.down.push(restore(table, key, doomed));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return moved;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `down` half. A key whose own table or whose target is being dropped has no `add constraint`
|
|
118
|
+
* that could run at all, so it gets the SAME note `unrestorableDrop` gives one — the text is
|
|
119
|
+
* `unrestorableNote`'s, in `foreign-key.ts`, and not a second spelling here. One failed rollback
|
|
120
|
+
* has one wording whichever module emitted it (axiom 2); these two had already drifted, this one
|
|
121
|
+
* naming no table at all while `foreign-key-plan.ts` named the target.
|
|
122
|
+
*
|
|
123
|
+
* The table it names is the key's OWN when that is the one going: a constraint whose table is
|
|
124
|
+
* gone is the more proximate reason there is nothing to add it back onto.
|
|
125
|
+
*/
|
|
126
|
+
function restore(
|
|
127
|
+
table: TableDescription,
|
|
128
|
+
key: ForeignKeyDescription,
|
|
129
|
+
doomed: ReadonlySet<string>,
|
|
130
|
+
): string {
|
|
131
|
+
if (!doomed.has(table.name) && !doomed.has(key.referencedTable)) {
|
|
132
|
+
return addForeignKey(table.name, key);
|
|
133
|
+
}
|
|
134
|
+
return unrestorableNote(
|
|
135
|
+
table.name,
|
|
136
|
+
key.name,
|
|
137
|
+
doomed.has(table.name) ? table.name : key.referencedTable,
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/sql-type.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single responsibility: the physical Postgres type a declared column KIND becomes. One table, so
|
|
2
|
+
// the statement that writes a column, the snapshot that records it and the pass that decides
|
|
3
|
+
// whether a retype is happening at all cannot disagree about what `char` means.
|
|
4
|
+
//
|
|
5
|
+
// Split out of `generate.ts` for `retype-keys.ts`, which has to answer "does this column's type
|
|
6
|
+
// move" ABOVE `diffTable` — a foreign key over a retyped column lives in another table's record.
|
|
7
|
+
|
|
8
|
+
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
9
|
+
uuid: 'uuid',
|
|
10
|
+
text: 'text',
|
|
11
|
+
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
12
|
+
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
13
|
+
// Without the length no currency ever fits the constraint the same statement demands.
|
|
14
|
+
char: 'char(3)',
|
|
15
|
+
boolean: 'boolean',
|
|
16
|
+
integer: 'integer',
|
|
17
|
+
bigint: 'bigint',
|
|
18
|
+
numeric: 'numeric',
|
|
19
|
+
timestamptz: 'timestamptz',
|
|
20
|
+
date: 'date',
|
|
21
|
+
jsonb: 'jsonb',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A kind this table does not name passes through verbatim — an app's own domain, an enum type a
|
|
26
|
+
* hand-written migration created.
|
|
27
|
+
*
|
|
28
|
+
* `Object.hasOwn` and not a bare index: `kind` is DATA, so `SQL_TYPES['constructor']` answered the
|
|
29
|
+
* `Object` function and `type ${wanted}` spliced its source into a statement, while `'__proto__'`
|
|
30
|
+
* answered `[object Object]`. Guarded, both behave like every other unknown kind and pass through
|
|
31
|
+
* as themselves. Measured across the package's 766 tests: no other input's answer moves.
|
|
32
|
+
*/
|
|
33
|
+
export function sqlType(kind: string): string {
|
|
34
|
+
return Object.hasOwn(SQL_TYPES, kind) ? (SQL_TYPES[kind] ?? kind) : kind;
|
|
35
|
+
}
|
package/src/sql.ts
CHANGED
|
@@ -132,19 +132,37 @@ export function identifier(name: string): SqlFragment {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* A quoted string literal
|
|
136
|
-
*
|
|
137
|
-
*
|
|
135
|
+
* A quoted string literal Postgres reads IDENTICALLY under both settings of
|
|
136
|
+
* `standard_conforming_strings`. Utility and DDL statements (`CREATE DATABASE`, `COMMENT ON`,
|
|
137
|
+
* `create table … default …`) reject bound parameters, so this is the only place a value may be
|
|
138
|
+
* inlined. Never reach for it in a query: `sql` binds parameters there.
|
|
138
139
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
140
|
+
* **It DOES receive caller input, and this comment said otherwise until 2026-08-25.**
|
|
141
|
+
* `column-default.ts:43` renders `ColumnDefaultLike` here, which is an app's own
|
|
142
|
+
* `.default('C:\\logs')` crossing the tier seam from `@ultimat3/entity` — nothing validates it and
|
|
143
|
+
* no `identifier()` guards it. (The package's two other callers are safe by CONSTRUCTION, not by
|
|
144
|
+
* input: `readonly-role.ts:71` sits in the same `sql` template as an `identifier(role)` that throws
|
|
145
|
+
* first, and `branch.ts:85` runs after an already-awaited `identifier(base)`.)
|
|
146
|
+
*
|
|
147
|
+
* Doubling the quote is not the whole rule. That GUC is settable per session, per database and per
|
|
148
|
+
* role and `SET` needs no privilege, and with it `off` a backslash escapes the character after it
|
|
149
|
+
* inside an ordinary `'…'`. Measured on 18.4 through `generateMigration`: `.default('C:\\logs')`
|
|
150
|
+
* emits `default 'C:\logs'`, which stores `C:\logs` with the GUC on and **`C:logs`** with it off —
|
|
151
|
+
* a column defaulting to a value nobody wrote, with no error anywhere. A value ENDING in a
|
|
152
|
+
* backslash is worse than wrong: the escaped quote leaves the literal unterminated and the text
|
|
153
|
+
* after it is string data until the next `'` puts the remainder back into code position.
|
|
154
|
+
*
|
|
155
|
+
* `E'…'` fixes the dialect in the text itself rather than trusting a setting, so both readings
|
|
156
|
+
* agree — **only** when the value actually carries a backslash. Without one there is no escape
|
|
157
|
+
* mechanism for the two settings to disagree about, so every migration already generated stays byte
|
|
158
|
+
* for byte what it was and nothing regenerates spuriously; both tracked apps have applied
|
|
159
|
+
* migrations on disk with hashes over this text. Same rule, same measurement, as
|
|
160
|
+
* `packages/entity/src/sql-literal.ts`, which is where it was first written and which adopts this
|
|
161
|
+
* one — tier 1 holds it, tier 2 imports down.
|
|
145
162
|
*/
|
|
146
163
|
export function literal(value: string): SqlFragment {
|
|
147
|
-
|
|
164
|
+
const quoted = value.replaceAll("'", "''");
|
|
165
|
+
return raw(value.includes('\\') ? `E'${quoted.replaceAll('\\', '\\\\')}'` : `'${quoted}'`);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/** `a, b, c` — the one blessed way to build an IN list or a column list. */
|