@ultimat3/db 13.0.0 → 15.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,231 @@
1
+ // Single responsibility: what a schema difference is CALLED and what its `fix:` line says — one
2
+ // constructor per `DriftKind`, and nothing that compares anything. Split out of `drift.ts` at the
3
+ // 500-line ceiling, along the seam that file already drew: comparison decides *whether* two
4
+ // schemas disagree, and this decides how the disagreement reads.
5
+ //
6
+ // The rendered `X_DB_DRIFT` output is byte-for-byte pinned by the framework contract and
7
+ // duplicated in `@ultimat3/entity` — do not reword a `cause` without changing both.
8
+ //
9
+ // Two rules run through every one of them. A `fix:` is a command the reader can RUN: `x db
10
+ // migrate` where the migration has not been applied, and the statement itself where it has, since
11
+ // re-running the migrator applies nothing a ledger row already claims. And a difference names the
12
+ // declared side's own spelling, never the catalog's, because the catalog's is Postgres' rewriting.
13
+
14
+ import { onDeleteRule, rebuildForeignKey } from './foreign-key';
15
+ import type { CheckDescription, ForeignKeyDescription } from './introspect';
16
+ import type { Migration } from './migrate';
17
+
18
+ export type DriftKind =
19
+ | 'unexpected-column'
20
+ | 'missing-column'
21
+ | 'changed-column'
22
+ | 'unexpected-table'
23
+ | 'missing-table'
24
+ | 'unknown-schema'
25
+ | 'missing-index'
26
+ | 'changed-index'
27
+ | 'missing-check'
28
+ | 'missing-foreign-key'
29
+ | 'changed-foreign-key';
30
+
31
+ export interface DriftDifference {
32
+ readonly kind: DriftKind;
33
+ readonly table: string;
34
+ readonly column: string | null;
35
+ readonly cause: string;
36
+ readonly fix: string;
37
+ }
38
+
39
+ export interface DriftReport {
40
+ readonly ok: boolean;
41
+ readonly differences: readonly DriftDifference[];
42
+ }
43
+
44
+ export function unexpectedColumn(table: string, column: string): DriftDifference {
45
+ return {
46
+ kind: 'unexpected-column',
47
+ table,
48
+ column,
49
+ // Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
50
+ cause: `table "${table}" has column "${column}" not present in any migration`,
51
+ fix: `x db gen "add ${column}"`,
52
+ };
53
+ }
54
+
55
+ export function missingColumn(table: string, column: string): DriftDifference {
56
+ return {
57
+ kind: 'missing-column',
58
+ table,
59
+ column,
60
+ cause: `table "${table}" is missing column "${column}" that migrations declare`,
61
+ fix: 'x db migrate',
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The column exists on both sides and one of them lets it be `NULL`.
67
+ *
68
+ * This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT
69
+ * NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the
70
+ * strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing
71
+ * a human has to remember. Nobody did, and `compareTable` compared columns by name and by type
72
+ * while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever
73
+ * against an entity schema that said otherwise, with `ok: true` on every check. The first
74
+ * `undefined` write then lands as `NULL` and crashes three services away from the migration.
75
+ *
76
+ * `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
77
+ * `set not null`, so naming it would send a reader to a command that generates an empty migration.
78
+ */
79
+ export function changedColumn(
80
+ table: string,
81
+ column: string,
82
+ liveNullable: boolean,
83
+ ): DriftDifference {
84
+ const clause = liveNullable ? 'set not null' : 'drop not null';
85
+ return {
86
+ kind: 'changed-column',
87
+ table,
88
+ column,
89
+ cause: liveNullable
90
+ ? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
91
+ : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
92
+ fix:
93
+ `alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
94
+ (liveNullable ? ' — backfill the existing NULLs first' : ''),
95
+ };
96
+ }
97
+
98
+ export function unexpectedTable(table: string): DriftDifference {
99
+ return {
100
+ kind: 'unexpected-table',
101
+ table,
102
+ column: null,
103
+ cause: `table "${table}" is not present in any migration`,
104
+ fix: `x db gen "add ${table}"`,
105
+ };
106
+ }
107
+
108
+ export function missingTable(table: string): DriftDifference {
109
+ return {
110
+ kind: 'missing-table',
111
+ table,
112
+ column: null,
113
+ cause: `table "${table}" is declared by migrations but does not exist`,
114
+ fix: 'x db migrate',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Not a difference between two schemas but the absence of one to compare against — reported
120
+ * through the same channel so it reaches an operator, since a check that quietly answered "clean"
121
+ * because it had nothing to check is the one failure mode drift detection cannot have.
122
+ */
123
+ export function unknownSchema(migrations: readonly Migration[]): DriftDifference {
124
+ const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
125
+ return {
126
+ kind: 'unknown-schema',
127
+ table: '',
128
+ column: null,
129
+ cause:
130
+ `migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
131
+ 'cannot be established',
132
+ // The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
133
+ // the same condition. It used to lead with `x db gen`, which raises that error and whose own
134
+ // fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
135
+ // pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
136
+ fix:
137
+ `git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
138
+ `delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
139
+ };
140
+ }
141
+
142
+ export function missingIndex(table: string, index: string): DriftDifference {
143
+ return {
144
+ kind: 'missing-index',
145
+ table,
146
+ column: null,
147
+ cause: `table "${table}" is missing index "${index}" that migrations declare`,
148
+ fix: 'x db migrate',
149
+ };
150
+ }
151
+
152
+ export function changedIndex(table: string, index: string, detail: string): DriftDifference {
153
+ return {
154
+ kind: 'changed-index',
155
+ table,
156
+ column: null,
157
+ cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
158
+ fix: 'x db migrate',
159
+ };
160
+ }
161
+
162
+ /**
163
+ * A CHECK a migration declares and the catalog does not hold.
164
+ *
165
+ * There is no `changed-check` beside it and there never will be, for the reason
166
+ * `IndexDescription.where` gives: `pg_get_constraintdef` answers Postgres' own rewriting —
167
+ * `status in ('draft','published')` reads back as `CHECK ((status = ANY (ARRAY['draft'::text,
168
+ * 'published'::text])))` — so a text comparison reports drift on a correct database forever, and
169
+ * normalising it is an expression parser competing with the server's. Presence is not text.
170
+ *
171
+ * The `fix` is the statement, not `x db migrate`: the migration that declares this constraint is
172
+ * already in the ledger, so re-running the migrator applies nothing. Same reasoning as
173
+ * `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling of
174
+ * the predicate, which is what makes an executable fix possible at all.
175
+ */
176
+ export function missingCheck(table: string, check: CheckDescription): DriftDifference {
177
+ return {
178
+ kind: 'missing-check',
179
+ table,
180
+ column: null,
181
+ cause: `table "${table}" is missing check constraint "${check.name}" that migrations declare`,
182
+ // The command rides on the same line as the statement, and not only because `check` is a
183
+ // banned advice word the `errors` gate demands a command beside: writing the migration is half
184
+ // the repair and applying it is the other half, and `changedColumn`'s bare `# in a new
185
+ // migration` leaves the second half to be guessed.
186
+ fix:
187
+ `alter table "${table}" add constraint "${check.name}" ` +
188
+ `check (${check.expression}); # in a new migration, then x db migrate`,
189
+ };
190
+ }
191
+
192
+ export function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
193
+ return {
194
+ kind: 'missing-foreign-key',
195
+ table,
196
+ column: null,
197
+ cause:
198
+ `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
199
+ `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
200
+ fix: 'x db migrate',
201
+ };
202
+ }
203
+
204
+ /**
205
+ * The key points where it was declared to point and one side's `on delete` rule is not the other's
206
+ * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
207
+ * there, and what changed is what happens to the child rows.
208
+ *
209
+ * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
210
+ * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
211
+ * naming a command would send a reader to one that generates an empty migration. Same reasoning
212
+ * as `changedColumn`.
213
+ */
214
+ export function changedForeignKey(
215
+ table: string,
216
+ declared: ForeignKeyDescription,
217
+ held: ForeignKeyDescription,
218
+ ): DriftDifference {
219
+ const rule = onDeleteRule(held.onDelete);
220
+ return {
221
+ kind: 'changed-foreign-key',
222
+ table,
223
+ column: null,
224
+ cause:
225
+ `foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
226
+ `"${declared.referencedTable}" ` +
227
+ `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
228
+ 'migrations declare',
229
+ fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
230
+ };
231
+ }
package/src/drift.ts CHANGED
@@ -4,200 +4,35 @@
4
4
  // by the framework contract; `x verify` fails on it and `--json` carries every difference.
5
5
 
6
6
  import { baseClient, type DbClient } from './client';
7
+ import type { DriftDifference } from './drift-findings';
8
+ import {
9
+ changedColumn,
10
+ changedForeignKey,
11
+ changedIndex,
12
+ missingCheck,
13
+ missingColumn,
14
+ missingForeignKey,
15
+ missingIndex,
16
+ missingTable,
17
+ unexpectedColumn,
18
+ unexpectedTable,
19
+ unknownSchema,
20
+ } from './drift-findings';
7
21
  import { DbError } from './errors';
8
- import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
22
+ import { foreignKeyTarget, onDeleteRule } from './foreign-key';
9
23
  import { indexMethodOf } from './index-method';
10
- import {
11
- type ForeignKeyDescription,
12
- findTable,
13
- introspect,
14
- type SchemaDescription,
15
- type TableDescription,
16
- } from './introspect';
24
+ import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
17
25
  import { type LedgerRow, type Migration, readLedger } from './migrate';
18
26
 
19
- export type DriftKind =
20
- | 'unexpected-column'
21
- | 'missing-column'
22
- | 'changed-column'
23
- | 'unexpected-table'
24
- | 'missing-table'
25
- | 'unknown-schema'
26
- | 'missing-index'
27
- | 'changed-index'
28
- | 'missing-foreign-key'
29
- | 'changed-foreign-key';
30
-
31
- export interface DriftDifference {
32
- readonly kind: DriftKind;
33
- readonly table: string;
34
- readonly column: string | null;
35
- readonly cause: string;
36
- readonly fix: string;
37
- }
27
+ // Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
28
+ // split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
29
+ export type { DriftDifference, DriftKind } from './drift-findings';
38
30
 
39
31
  export interface DriftReport {
40
32
  readonly ok: boolean;
41
33
  readonly differences: readonly DriftDifference[];
42
34
  }
43
35
 
44
- function unexpectedColumn(table: string, column: string): DriftDifference {
45
- return {
46
- kind: 'unexpected-column',
47
- table,
48
- column,
49
- // Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
50
- cause: `table "${table}" has column "${column}" not present in any migration`,
51
- fix: `x db gen "add ${column}"`,
52
- };
53
- }
54
-
55
- function missingColumn(table: string, column: string): DriftDifference {
56
- return {
57
- kind: 'missing-column',
58
- table,
59
- column,
60
- cause: `table "${table}" is missing column "${column}" that migrations declare`,
61
- fix: 'x db migrate',
62
- };
63
- }
64
-
65
- /**
66
- * The column exists on both sides and one of them lets it be `NULL`.
67
- *
68
- * This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT
69
- * NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the
70
- * strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing
71
- * a human has to remember. Nobody did, and `compareTable` compared columns by name and by type
72
- * while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever
73
- * against an entity schema that said otherwise, with `ok: true` on every check. The first
74
- * `undefined` write then lands as `NULL` and crashes three services away from the migration.
75
- *
76
- * `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
77
- * `set not null`, so naming it would send a reader to a command that generates an empty migration.
78
- */
79
- function changedColumn(table: string, column: string, liveNullable: boolean): DriftDifference {
80
- const clause = liveNullable ? 'set not null' : 'drop not null';
81
- return {
82
- kind: 'changed-column',
83
- table,
84
- column,
85
- cause: liveNullable
86
- ? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
87
- : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
88
- fix:
89
- `alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
90
- (liveNullable ? ' — backfill the existing NULLs first' : ''),
91
- };
92
- }
93
-
94
- function unexpectedTable(table: string): DriftDifference {
95
- return {
96
- kind: 'unexpected-table',
97
- table,
98
- column: null,
99
- cause: `table "${table}" is not present in any migration`,
100
- fix: `x db gen "add ${table}"`,
101
- };
102
- }
103
-
104
- function missingTable(table: string): DriftDifference {
105
- return {
106
- kind: 'missing-table',
107
- table,
108
- column: null,
109
- cause: `table "${table}" is declared by migrations but does not exist`,
110
- fix: 'x db migrate',
111
- };
112
- }
113
-
114
- /**
115
- * Not a difference between two schemas but the absence of one to compare against — reported
116
- * through the same channel so it reaches an operator, since a check that quietly answered "clean"
117
- * because it had nothing to check is the one failure mode drift detection cannot have.
118
- */
119
- function unknownSchema(migrations: readonly Migration[]): DriftDifference {
120
- const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
121
- return {
122
- kind: 'unknown-schema',
123
- table: '',
124
- column: null,
125
- cause:
126
- `migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
127
- 'cannot be established',
128
- // The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
129
- // the same condition. It used to lead with `x db gen`, which raises that error and whose own
130
- // fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
131
- // pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
132
- fix:
133
- `git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
134
- `delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
135
- };
136
- }
137
-
138
- function missingIndex(table: string, index: string): DriftDifference {
139
- return {
140
- kind: 'missing-index',
141
- table,
142
- column: null,
143
- cause: `table "${table}" is missing index "${index}" that migrations declare`,
144
- fix: 'x db migrate',
145
- };
146
- }
147
-
148
- function changedIndex(table: string, index: string, detail: string): DriftDifference {
149
- return {
150
- kind: 'changed-index',
151
- table,
152
- column: null,
153
- cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
154
- fix: 'x db migrate',
155
- };
156
- }
157
-
158
- function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
159
- return {
160
- kind: 'missing-foreign-key',
161
- table,
162
- column: null,
163
- cause:
164
- `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
165
- `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
166
- fix: 'x db migrate',
167
- };
168
- }
169
-
170
- /**
171
- * The key points where it was declared to point and one side's `on delete` rule is not the other's
172
- * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
173
- * there, and what changed is what happens to the child rows.
174
- *
175
- * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
176
- * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
177
- * naming a command would send a reader to one that generates an empty migration. Same reasoning
178
- * as `changedColumn`.
179
- */
180
- function changedForeignKey(
181
- table: string,
182
- declared: ForeignKeyDescription,
183
- held: ForeignKeyDescription,
184
- ): DriftDifference {
185
- const rule = onDeleteRule(held.onDelete);
186
- return {
187
- kind: 'changed-foreign-key',
188
- table,
189
- column: null,
190
- cause:
191
- `foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
192
- `"${declared.referencedTable}" ` +
193
- `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
194
- 'migrations declare',
195
- fix:
196
- `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}` +
197
- ' # in a new migration',
198
- };
199
- }
200
-
201
36
  /**
202
37
  * Indexes migrations declare, against the ones the catalog holds — by column list and by
203
38
  * uniqueness, which is what caught a composite index rebuilt with its columns the other way round
@@ -316,6 +151,33 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
316
151
  return differences;
317
152
  }
318
153
 
154
+ /**
155
+ * CHECK constraints migrations declare, against the NAMES the catalog holds — `checkNames`, which
156
+ * is a separate field from `checks` precisely so this comparison cannot reach a definition it must
157
+ * not read (`introspect.ts`).
158
+ *
159
+ * Two absences, and they mean opposite things. `expected.checks` absent is a sidecar written
160
+ * before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames`
161
+ * absent is a description that never asked the catalog — a stub, a fake client's rows, a
162
+ * `TableDescription` built by hand — and reading that as "the database holds none" is one finding
163
+ * per declared constraint against a database nobody looked at. `introspect()` always answers with
164
+ * the field, `[]` included, so a real read is never mistaken for an unread one.
165
+ *
166
+ * Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state:
167
+ * a NOT NULL, an `enumerated()` column's old anonymous form, a constraint an extension brought and
168
+ * every hand-written CHECK an app has ever added would each be a finding against a database that
169
+ * is exactly right.
170
+ */
171
+ function compareChecks(live: TableDescription, expected: TableDescription): DriftDifference[] {
172
+ const declared = expected.checks;
173
+ const held = live.checkNames;
174
+ if (declared === undefined || held === undefined) return [];
175
+ const present = new Set(held);
176
+ return declared
177
+ .filter((check) => !present.has(check.name))
178
+ .map((check) => missingCheck(live.name, check));
179
+ }
180
+
319
181
  /**
320
182
  * A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
321
183
  * adds the constraint with the key. Both sides are therefore read through the union of the two
@@ -352,6 +214,7 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
352
214
  }
353
215
  }
354
216
  differences.push(...compareIndexes(live, expected));
217
+ differences.push(...compareChecks(live, expected));
355
218
  differences.push(...compareForeignKeys(live, expected));
356
219
  return differences;
357
220
  }
@@ -2,6 +2,7 @@
2
2
  // tier 1 and may never import `entity` (tier 2), so a snapshot arrives as a parameter and every
3
3
  // part of it — a column's `on delete` rule included — crosses the seam by shape or not at all.
4
4
 
5
+ import type { ColumnDefaultLike } from './column-default';
5
6
  import type { IndexMethod } from './index-method';
6
7
 
7
8
  /** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
@@ -32,6 +33,45 @@ export interface ColumnDescriptionLike {
32
33
  * was a `23502`, because nothing computed it.
33
34
  */
34
35
  readonly generated?: string | undefined;
36
+ /**
37
+ * What the column defaults to, when the declaration carries the value and not only the flag.
38
+ * Optional for the reason `onDelete` and `generated` are: this package cannot import
39
+ * `@ultimat3/entity`, so a field that is not on the projection reaches no DDL at all.
40
+ *
41
+ * `hasDefault` beside it is NOT redundant and is not being replaced. It is the older, narrower
42
+ * fact — "this column defaults to something" — and it is what `generatedClause` reads to refuse
43
+ * a column that is both generated and defaulted. Where `hasDefault` is true and this is absent,
44
+ * `defaultExpression` falls back to the two defaults inferable from the kind and everything else
45
+ * is REPORTED as unrendered rather than dropped (`unrendered.ts`).
46
+ */
47
+ readonly default?: ColumnDefaultLike | undefined;
48
+ }
49
+
50
+ /**
51
+ * Structurally assignment-compatible with `@ultimat3/entity`'s `InvariantDescription`.
52
+ *
53
+ * An invariant is written once and enforced twice — in the app on every write, and in Postgres as
54
+ * a CHECK or a unique index. The second half reached no SQL at all until 2026-08-25, because this
55
+ * mirror had no `invariants` field: a regenerated migration silently held none of them, including
56
+ * the composite UNIQUE that `upsertAll`'s `on conflict` is inferred against, so a replay-safe
57
+ * write became a duplicate row on a database the framework itself generated.
58
+ */
59
+ export interface InvariantDescriptionLike {
60
+ /** The rule's own name. `<table>_<name>_check` / `_key` is the constraint it becomes. */
61
+ readonly name: string;
62
+ /** `assert` is a rule only the app can run — no SQL, and nothing for a migration to emit. */
63
+ readonly kind: 'check' | 'unique' | 'assert';
64
+ readonly message: string;
65
+ /** The predicate for a `check`, the column list for a `unique`, `null` for an `assert`. */
66
+ readonly sql: string | null;
67
+ /** Partial-constraint predicate, e.g. `deleted_at is null`. `null` covers every row. */
68
+ readonly where: string | null;
69
+ /**
70
+ * The physical columns a `unique` names, when the declaration carries them. Optional, and
71
+ * `uniqueColumns()` falls back to splitting `sql` when it is absent — see the argument in
72
+ * `invariant-ddl.ts` for why that fallback is a validated re-read and not a name parsed back.
73
+ */
74
+ readonly columns?: readonly string[] | undefined;
35
75
  }
36
76
 
37
77
  /**
@@ -66,4 +106,10 @@ export interface EntityDescriptionLike {
66
106
  readonly primaryKey: readonly string[];
67
107
  readonly columns: readonly ColumnDescriptionLike[];
68
108
  readonly indexes: readonly IndexDescriptionLike[];
109
+ /**
110
+ * The domain rules the database must hold too. Optional so no existing description changes
111
+ * shape, exactly as `onDelete`, `generated` and `using` are — and absent reads as "declares
112
+ * none", which is what every hand-built description in this package's own tests is.
113
+ */
114
+ readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
69
115
  }
@@ -5,6 +5,7 @@
5
5
  import type { EntityDescriptionLike } from './entity-shape';
6
6
  import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
7
7
  import type { ForeignKeyDescription, TableDescription } from './introspect';
8
+ import { identifier } from './sql';
8
9
 
9
10
  /** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
10
11
  export interface Plan {
@@ -137,10 +138,17 @@ export interface ConstraintPlans {
137
138
  readonly doomed: ReadonlySet<string>;
138
139
  }
139
140
 
140
- /** A key whose target is being dropped: gone on the way up, a note on the way back. */
141
+ /**
142
+ * A key whose target is being dropped: gone on the way up, a note on the way back.
143
+ *
144
+ * The note goes through `identifier` too. A `--` comment ends at the first newline, so a name
145
+ * holding one is a second command on the line after it — the same escape `columnClause` closed,
146
+ * one quoting rule short of the statement above it.
147
+ */
141
148
  function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
142
149
  preDrops.up.push(dropForeignKey(table, constraint));
143
150
  preDrops.down.push(
144
- `-- constraint "${constraint}" on "${table}" cannot be restored; "${target}" is gone`,
151
+ `-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
152
+ `cannot be restored; ${identifier(target).text} is gone`,
145
153
  );
146
154
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { assert } from '@ultimat3/core';
6
6
  import type { ForeignKeyDescription } from './introspect';
7
+ import { identifier } from './sql';
7
8
 
8
9
  /**
9
10
  * `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
@@ -51,7 +52,16 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
51
52
  return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
52
53
  }
53
54
 
54
- const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ');
55
+ /**
56
+ * Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
57
+ * now goes through. A name that closes its own quote produced a real `drop table` through
58
+ * `generateMigration` once already, out of `columnClause`, and every name below arrives the same
59
+ * way: from a projection this package cannot typecheck, or from a `.snapshot.json` on disk that
60
+ * anything may edit. Being unreachable with a hostile name today is a property of the CALLERS, not
61
+ * of this file, and it survives exactly until the next refactor.
62
+ */
63
+ const quoted = (names: readonly string[]): string =>
64
+ names.map((name) => identifier(name).text).join(', ');
55
65
 
56
66
  /**
57
67
  * A statement of its own, never a clause inside `create table`. Inline, the constraint is created
@@ -76,14 +86,48 @@ export function addForeignKey(table: string, key: ForeignKeyDescription): string
76
86
  `references(() => target.id, { onDelete: 'cascade' }) # cascade | restrict | set null`,
77
87
  );
78
88
  return (
79
- `alter table "${table}" add constraint "${key.name}" ` +
89
+ `alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
80
90
  `foreign key (${quoted(key.columns)}) ` +
81
- `references "${key.referencedTable}" (${quoted(key.referencedColumns)})` +
91
+ `references ${identifier(key.referencedTable).text} (${quoted(key.referencedColumns)})` +
82
92
  `${rule === null ? '' : ` on delete ${rule}`};`
83
93
  );
84
94
  }
85
95
 
86
96
  /** The reverse. Dropping a constraint loses nothing the database cannot rebuild. */
87
97
  export function dropForeignKey(table: string, constraint: string): string {
88
- return `alter table "${table}" drop constraint "${constraint}";`;
98
+ return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
99
+ }
100
+
101
+ /**
102
+ * The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
103
+ * `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
104
+ *
105
+ * It lives here, beside the two writers, because it is the one caller reading values neither of
106
+ * them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
107
+ * writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
108
+ * backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
109
+ * rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
110
+ * `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
111
+ * never a throw — a drift check that raises in place of its report hands the caller an exception
112
+ * where a verdict was asked for. The constraint is still named, because it is the only thing
113
+ * identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
114
+ * runs this string either way.
115
+ */
116
+ export function rebuildForeignKey(
117
+ table: string,
118
+ declared: ForeignKeyDescription,
119
+ held: ForeignKeyDescription,
120
+ ): string {
121
+ // The writers are ASKED whether they can write the pair — never a second copy of their rules
122
+ // beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
123
+ // the thrown value.
124
+ try {
125
+ return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
126
+ } catch {
127
+ return (
128
+ `drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
129
+ 'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
130
+ 'write this pair'
131
+ );
132
+ }
89
133
  }