@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.
@@ -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
+ }
@@ -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. Postgres utility statements (`CREATE DATABASE`, `COMMENT ON`) reject
136
- * bound parameters, so this is the only place a value may be inlined — and it escapes quotes.
137
- * Never reach for it in a query: `sql` binds parameters there.
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
- * The doubling is only an escape while `standard_conforming_strings` is `on`, which has been the
140
- * server default since 9.1: with it OFF, a backslash escapes the quote that follows and a value
141
- * ending in one closes the literal early. So this is safe for framework-supplied names — a
142
- * database, a schema, a comment this repo writes and is NOT an escape for untrusted text under
143
- * an arbitrary server configuration. Nothing passes it caller input today; if something must,
144
- * bind a parameter instead, or send `E''`-style quoting from a statement that can take one.
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
- return raw(`'${value.replaceAll("'", "''")}'`);
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. */