@ultimat3/db 1.1.0 → 2.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,53 @@
1
+ // Single responsibility: the one way to declare that a loop of statements is deliberate, so a
2
+ // statement-level diagnostic reports the loops nobody argued for and stays quiet about the rest.
3
+ // A scope with a written reason — never a comment pragma and never a config list of exempt call
4
+ // sites (axiom 1), because both put the argument somewhere other than the loop it defends.
5
+
6
+ // `node:` because Bun exposes no native async-context primitive: the reason has to outlive every
7
+ // `await` inside the scope, and `AsyncLocalStorage` is the only thing that carries a value across
8
+ // them. A module-scope variable would be shared by two concurrent loops.
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import { assert } from '@ultimat3/core';
11
+
12
+ const storage = new AsyncLocalStorage<string>();
13
+
14
+ /**
15
+ * Run `fn` with every statement it issues — at any depth, across every `await` — marked expected
16
+ * and carrying `reason`. The reason rides on the `StatementEvent` (`expected`), so a diagnostic
17
+ * that buffers a request's statements and judges them at the end still holds the argument long
18
+ * after this scope closed.
19
+ *
20
+ * What it suppresses is a **verdict**, not the statements: they are still sent, still observed,
21
+ * still spans on the trace. A detector counting repeats gets the author's reason for this one
22
+ * instead of guessing, and the loop stays visible to everything that measures rather than judges.
23
+ *
24
+ * `reason` is required and non-blank because it *is* the mechanism — an exemption with no argument
25
+ * is a pragma, and the next reader cannot tell a considered loop from a silenced one. Nesting keeps
26
+ * the innermost reason: the closest scope is the one describing this loop.
27
+ *
28
+ * ```ts
29
+ * // one indexed lookup per search field beats one unindexed OR across all of them
30
+ * return expectedQueryLoop('search runs one indexed lookup per field', async () => {
31
+ * for (const field of fields) hits.push(...(await repo.list({ where: [eq(field, term)] })));
32
+ * return hits;
33
+ * });
34
+ * ```
35
+ */
36
+ export function expectedQueryLoop<T>(reason: string, fn: () => T): T {
37
+ assert(
38
+ reason.trim() !== '',
39
+ 'expectedQueryLoop() was given a blank reason, so the loop it silences carries no argument',
40
+ "pass why the loop is optimal: expectedQueryLoop('one indexed lookup per field', fn)",
41
+ );
42
+ return storage.run(reason, fn);
43
+ }
44
+
45
+ /**
46
+ * The innermost enclosing reason, or `undefined` outside every scope — which is every statement in
47
+ * an app that never calls `expectedQueryLoop`. Read by the two funnels when an observer is
48
+ * installed, and by nothing else: a diagnostic reads `StatementEvent.expected`, which is the same
49
+ * answer captured at the moment the statement settled.
50
+ */
51
+ export function expectedQueryLoopReason(): string | undefined {
52
+ return storage.getStore();
53
+ }
@@ -0,0 +1,32 @@
1
+ // Single responsibility: the PGlite driver fake the adapter tests record statements against.
2
+ // Shared rather than copied for the same reason `fake-reservable.ts` is: the assertion in every
3
+ // one of these tests is the recorded ORDER, and two copies of the recorder drift into two orders.
4
+
5
+ import type { PgliteDriver, PgliteResult } from './pglite';
6
+
7
+ /** One statement as the driver received it — the text after binding, and the bound values. */
8
+ export interface Recorded {
9
+ readonly text: string;
10
+ readonly values: readonly unknown[];
11
+ }
12
+
13
+ export type RecordingPgliteDriver = PgliteDriver & {
14
+ readonly calls: Recorded[];
15
+ closed: number;
16
+ };
17
+
18
+ /** A driver that answers every statement with `result` and remembers the order it saw them in. */
19
+ export function fakeDriver(result: PgliteResult): RecordingPgliteDriver {
20
+ const calls: Recorded[] = [];
21
+ return {
22
+ calls,
23
+ closed: 0,
24
+ async query(text, values) {
25
+ calls.push({ text, values: values ?? [] });
26
+ return result;
27
+ },
28
+ async close() {
29
+ this.closed += 1;
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,50 @@
1
+ // Single responsibility: a `ReservableClient` whose pin is countable, wrapped over any `DbClient`.
2
+ // The leak it exists to catch is invisible to the recording client — the statements are identical
3
+ // whether or not the reservation ever came back, and only the counter says which happened. Shared
4
+ // rather than copied: a second copy of a fixture asserts less than the first, silently.
5
+
6
+ import type { DbClient, ReservableClient } from './client';
7
+
8
+ /** Pins taken and pins given back. A leak is `reserves > releases`; the fix makes them equal. */
9
+ export interface PinCounts {
10
+ reserves: number;
11
+ releases: number;
12
+ }
13
+
14
+ export interface ReservableFake {
15
+ readonly client: ReservableClient;
16
+ readonly pins: PinCounts;
17
+ }
18
+
19
+ /**
20
+ * Wrap `inner` in a pool that counts its reservations. `release()` is idempotent and
21
+ * `[Symbol.dispose]` is the same call, exactly as both real drivers are — so a double release
22
+ * counts once and only a genuine leak leaves the counters uneven.
23
+ */
24
+ export function reservableOver(inner: DbClient): ReservableFake {
25
+ const pins: PinCounts = { reserves: 0, releases: 0 };
26
+ return {
27
+ pins,
28
+ client: {
29
+ query: (fragment) => inner.query(fragment),
30
+ one: (fragment) => inner.one(fragment),
31
+ execute: (fragment) => inner.execute(fragment),
32
+ reserve: async () => {
33
+ pins.reserves += 1;
34
+ let held = true;
35
+ const release = (): void => {
36
+ if (!held) return;
37
+ held = false;
38
+ pins.releases += 1;
39
+ };
40
+ return {
41
+ query: (fragment) => inner.query(fragment),
42
+ one: (fragment) => inner.one(fragment),
43
+ execute: (fragment) => inner.execute(fragment),
44
+ release,
45
+ [Symbol.dispose]: release,
46
+ };
47
+ },
48
+ },
49
+ };
50
+ }
package/src/fake.ts CHANGED
@@ -36,9 +36,22 @@ function matches(stub: Stub, text: string): boolean {
36
36
  return typeof stub.match === 'string' ? text.includes(stub.match) : stub.match.test(text);
37
37
  }
38
38
 
39
+ /**
40
+ * The one statement a recording client cannot answer with silence. `migrate()` polls
41
+ * `pg_try_advisory_lock` until it wins or its deadline passes, so "no rows" reads as "another
42
+ * migrator holds it" — and every migration test in the framework, and in every app, would spend
43
+ * `MIGRATION_LOCK_WAIT_MS` waiting for a lock nobody holds before failing.
44
+ *
45
+ * Registered first, so `on(/pg_try_advisory_lock/, { rows: [{ locked: false }] })` still wins:
46
+ * later registrations override, and a test about contention says so out loud.
47
+ */
48
+ const DEFAULT_STUBS: readonly Stub[] = [
49
+ { match: /pg_try_advisory_lock/, response: { rows: [{ locked: true }] } },
50
+ ];
51
+
39
52
  export function createRecordingClient(): RecordingClient {
40
53
  const statements: RecordedStatement[] = [];
41
- const stubs: Stub[] = [];
54
+ const stubs: Stub[] = [...DEFAULT_STUBS];
42
55
 
43
56
  function respond(fragment: SqlFragment): StubResponse {
44
57
  statements.push({ text: fragment.text, values: [...fragment.values] });
@@ -61,7 +74,8 @@ export function createRecordingClient(): RecordingClient {
61
74
  },
62
75
  reset(): void {
63
76
  statements.length = 0;
64
- stubs.length = 0;
77
+ // Back to the defaults, never to nothing: `reset()` restores the client a test was handed.
78
+ stubs.splice(0, stubs.length, ...DEFAULT_STUBS);
65
79
  },
66
80
  async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
67
81
  return (respond(fragment).rows ?? []) as readonly T[];
@@ -0,0 +1,41 @@
1
+ // Single responsibility: what a foreign key *is* — where it points — and the two statements that
2
+ // add or drop one. `generate.ts` writes them and `drift.ts` compares them, and a generator that
3
+ // disagreed with a detector about whether two keys are the same key is drift on a correct database.
4
+
5
+ import type { ForeignKeyDescription } from './introspect';
6
+
7
+ /**
8
+ * A key's identity: its columns, its target table, its target columns — never its name. Postgres
9
+ * names an inline `references` clause `<table>_<column>_fkey` and a hand-written migration may have
10
+ * said `constraint fk_posts_org`; a key pointing the same way under another name is the same key.
11
+ *
12
+ * Both lists stay ordered, because a composite key is an ordered pairing: `(a, b) references t (x,
13
+ * y)` and `(b, a) references t (x, y)` are different constraints.
14
+ */
15
+ export function foreignKeyTarget(key: ForeignKeyDescription): string {
16
+ return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
17
+ }
18
+
19
+ const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ');
20
+
21
+ /**
22
+ * A statement of its own, never a clause inside `create table`. Inline, the constraint is created
23
+ * with the table, so the referenced table must already exist — and entity registration order is
24
+ * the app's import order, which has nothing to say about which table a `references()` points at.
25
+ * Two tables referencing each other cannot be expressed inline at all, in any order.
26
+ *
27
+ * The constraint is **named** here rather than left to Postgres' own convention, so the name the
28
+ * snapshot beside it records is a name this migration wrote and not a name it guessed.
29
+ */
30
+ export function addForeignKey(table: string, key: ForeignKeyDescription): string {
31
+ return (
32
+ `alter table "${table}" add constraint "${key.name}" ` +
33
+ `foreign key (${quoted(key.columns)}) ` +
34
+ `references "${key.referencedTable}" (${quoted(key.referencedColumns)});`
35
+ );
36
+ }
37
+
38
+ /** The reverse. Dropping a constraint loses nothing the database cannot rebuild. */
39
+ export function dropForeignKey(table: string, constraint: string): string {
40
+ return `alter table "${table}" drop constraint "${constraint}";`;
41
+ }
package/src/generate.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // Single responsibility: turn an entity snapshot into a timestamped, reversible migration.
2
- // `db` is tier 2 and cannot import `@ultimat3/entity`, so the snapshot arrives as a parameter —
2
+ // `db` is tier 1 and cannot import `@ultimat3/entity`, so the snapshot arrives as a parameter —
3
3
  // the CLI passes `describeEntities()` and the types below mirror `EntityDescription` field for
4
4
  // field. Every generated migration must be reversible; a drop that loses data refuses instead.
5
5
 
6
+ import { assert, systemClock } from '@ultimat3/core';
7
+ import { isDestructive } from './destructive';
6
8
  import { migrationIrreversible } from './errors';
9
+ import { addForeignKey, dropForeignKey, foreignKeyTarget } from './foreign-key';
7
10
  import {
8
11
  type ColumnDescription,
12
+ type ForeignKeyDescription,
9
13
  findTable,
14
+ type IndexDescription,
10
15
  type SchemaDescription,
11
16
  type TableDescription,
12
17
  } from './introspect';
@@ -24,14 +29,31 @@ export interface ColumnDescriptionLike {
24
29
  readonly references: string | null;
25
30
  }
26
31
 
32
+ /**
33
+ * Structurally assignment-compatible with `@ultimat3/entity`'s `IndexDescription`.
34
+ *
35
+ * The column list is carried, never recovered from `name`. Entity names an index
36
+ * `<table>_<a>_<b>_idx`, and that convention does not run backwards: two columns joined by `_`
37
+ * are one string, so a composite index read back out of its own name became the single column
38
+ * `"org_id_created_at"` — DDL Postgres answers `42703` and a migration nobody can apply.
39
+ */
40
+ export interface IndexDescriptionLike {
41
+ readonly name: string;
42
+ readonly columns: readonly string[];
43
+ readonly unique: boolean;
44
+ /** Partial index predicate as SQL, `null` when the index covers every row. */
45
+ readonly where: string | null;
46
+ /** `null` is Postgres' own default (`asc`), never written out. */
47
+ readonly order: 'asc' | 'desc' | null;
48
+ }
49
+
27
50
  /** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
28
51
  export interface EntityDescriptionLike {
29
52
  readonly name: string;
30
53
  readonly table: string;
31
54
  readonly primaryKey: readonly string[];
32
55
  readonly columns: readonly ColumnDescriptionLike[];
33
- /** Index names only, following entity's `<table>_<column>_idx` / `_key` convention. */
34
- readonly indexes: readonly string[];
56
+ readonly indexes: readonly IndexDescriptionLike[];
35
57
  }
36
58
 
37
59
  const SQL_TYPES: Readonly<Record<string, string>> = {
@@ -65,6 +87,16 @@ function defaultExpression(column: ColumnDescriptionLike): string | null {
65
87
  return null;
66
88
  }
67
89
 
90
+ /**
91
+ * The two ends of a `references()`, which entity renders as `"<table>.<column>"`. Read once: the
92
+ * clause that writes the constraint and the snapshot that records it must not disagree about what
93
+ * it points at, or drift reports a key the database holds exactly as declared.
94
+ */
95
+ function referenceParts(references: string): readonly [string, string] {
96
+ const [table = references, column = 'id'] = references.split('.');
97
+ return [table, column];
98
+ }
99
+
68
100
  function columnClause(column: ColumnDescriptionLike): string {
69
101
  const parts = [`"${column.column}"`, sqlType(column.kind)];
70
102
  const expression = defaultExpression(column);
@@ -72,44 +104,68 @@ function columnClause(column: ColumnDescriptionLike): string {
72
104
  if (column.notNull) parts.push('not null');
73
105
  if (column.unique && !column.primaryKey) parts.push('unique');
74
106
  if (column.check !== null) parts.push(`check (${column.check})`);
75
- if (column.references !== null) {
76
- const [refTable = column.references, refColumn = 'id'] = column.references.split('.');
77
- parts.push(`references "${refTable}" ("${refColumn}")`);
78
- }
107
+ // No `references` clause. A foreign key is `alter table … add constraint`, emitted after every
108
+ // table exists (`foreignKeyPlan`) inline it must point at a table that already exists, and
109
+ // entity registration order is the app's import order, which says nothing about that.
79
110
  return parts.join(' ');
80
111
  }
81
112
 
82
- export interface ParsedIndex {
83
- readonly name: string;
84
- readonly columns: readonly string[];
85
- readonly unique: boolean;
86
- }
87
-
88
113
  /**
89
114
  * A `unique` column clause already creates an index, and Postgres names it exactly what the
90
115
  * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
91
116
  * it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
92
117
  * Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
118
+ *
119
+ * A **partial** unique index is not that index: the column clause constrains every row, so
120
+ * skipping the partial one would silently widen the constraint the entity declared.
93
121
  */
94
122
  function impliedByColumnClause(
95
123
  entity: EntityDescriptionLike,
96
- index: ParsedIndex,
124
+ index: IndexDescriptionLike,
97
125
  added: ReadonlySet<string>,
98
126
  ): boolean {
99
127
  const [only] = index.columns;
100
- if (!index.unique || index.columns.length !== 1 || only === undefined) return false;
128
+ if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
129
+ return false;
130
+ }
101
131
  const column = entity.columns.find((each) => each.column === only);
102
132
  // `columnClause` writes `unique` under exactly this condition — keep the two in step.
133
+ //
134
+ // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
135
+ // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
136
+ // unsafe for exactly this reason — applying it turned a green typecheck red.
137
+ // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
103
138
  return column !== undefined && column.unique && !column.primaryKey && added.has(only);
104
139
  }
105
140
 
106
- /** Entity only records index names; the convention is what makes the columns recoverable. */
107
- export function parseIndexName(table: string, name: string): ParsedIndex {
108
- const unique = name.endsWith('_key');
109
- const prefix = `${table}_`;
110
- const withoutTable = name.startsWith(prefix) ? name.slice(prefix.length) : name;
111
- const middle = withoutTable.replace(/_(idx|key)$/, '');
112
- return { name, columns: middle === '' ? [] : [middle], unique };
141
+ /**
142
+ * The keys this entity declares, recorded so drift can see one dropped by hand. Recording
143
+ * `foreignKeys: []` while the same run emitted a constraint was a snapshot claiming a constraint
144
+ * does not exist that the migration beside it creates, and `compareTable` had nothing to compare —
145
+ * a key dropped on the database was invisible to every check the framework runs.
146
+ *
147
+ * The name is `<table>_<column>_fkey` what Postgres would have called an inline `references`
148
+ * clause — and `addForeignKey` now writes it out, so the snapshot records a name the migration
149
+ * beside it chose rather than one it guessed. It is still *not* what drift matches on: see
150
+ * `compareForeignKeys` in `drift.ts`.
151
+ *
152
+ * `onDelete` stays `null`. `entity()` carries the option and no clause here has ever spelled one,
153
+ * so a value written down would be a claim about the database that is not true.
154
+ */
155
+ function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
156
+ return entity.columns
157
+ .filter((column) => column.references !== null)
158
+ .map((column): ForeignKeyDescription => {
159
+ const [table, key] = referenceParts(column.references ?? '');
160
+ return {
161
+ name: `${entity.table}_${column.column}_fkey`,
162
+ columns: [column.column],
163
+ referencedTable: table,
164
+ referencedColumns: [key],
165
+ onDelete: null,
166
+ };
167
+ })
168
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
113
169
  }
114
170
 
115
171
  export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
@@ -130,11 +186,18 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
130
186
  name: entity.table,
131
187
  columns,
132
188
  primaryKey: [...entity.primaryKey],
133
- indexes: entity.indexes.map((name) => ({
134
- ...parseIndexName(entity.table, name),
189
+ // Whole, never partly: a snapshot that recorded the name and dropped the predicate made
190
+ // the next generation blind to a `where` or an `order` changing, and a partial index
191
+ // silently kept as a total one is a constraint the entity no longer declares.
192
+ indexes: entity.indexes.map((index) => ({
193
+ name: index.name,
194
+ columns: [...index.columns],
195
+ unique: index.unique,
135
196
  primary: false,
197
+ where: index.where,
198
+ order: index.order,
136
199
  })),
137
- foreignKeys: [],
200
+ foreignKeys: foreignKeysOf(entity),
138
201
  };
139
202
  });
140
203
  return { tables };
@@ -148,18 +211,29 @@ function createTable(entity: EntityDescriptionLike): readonly string[] {
148
211
  const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
149
212
  // Every column of a new table carries its own clause, so every `unique` one brings its index.
150
213
  const added = new Set(entity.columns.map((column) => column.column));
151
- for (const name of entity.indexes) {
152
- const index = parseIndexName(entity.table, name);
153
- if (index.columns.length === 0 || impliedByColumnClause(entity, index, added)) continue;
214
+ for (const index of entity.indexes) {
215
+ if (impliedByColumnClause(entity, index, added)) continue;
154
216
  statements.push(createIndex(entity.table, index));
155
217
  }
156
218
  return statements;
157
219
  }
158
220
 
159
- function createIndex(table: string, index: ParsedIndex): string {
221
+ /**
222
+ * Every part of the declaration reaches the statement: the whole column list in its declared
223
+ * order, the direction when one was asked for, and the predicate that makes it partial. A part
224
+ * dropped here is a constraint the database does not hold or an index the planner cannot use.
225
+ */
226
+ function createIndex(table: string, index: IndexDescriptionLike): string {
227
+ assert(
228
+ index.columns.length > 0,
229
+ `index "${index.name}" on "${table}" names no columns`,
230
+ `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
231
+ );
160
232
  const kind = index.unique ? 'create unique index' : 'create index';
161
- const columns = index.columns.map((column) => `"${column}"`).join(', ');
162
- return `${kind} "${index.name}" on "${table}" (${columns});`;
233
+ const direction = index.order === null ? '' : ` ${index.order}`;
234
+ const columns = index.columns.map((column) => `"${column}"${direction}`).join(', ');
235
+ const predicate = index.where === null ? '' : ` where (${index.where})`;
236
+ return `${kind} "${index.name}" on "${table}" (${columns})${predicate};`;
163
237
  }
164
238
 
165
239
  interface Plan {
@@ -167,6 +241,30 @@ interface Plan {
167
241
  readonly down: string[];
168
242
  }
169
243
 
244
+ /**
245
+ * Every foreign key the entity declares that the previous snapshot does not already record, as its
246
+ * own `add constraint`. One call site for both cases the generator has — a table being created and
247
+ * a `references()` added to a column that already exists — because they are one question: which of
248
+ * this entity's keys does the database not hold yet.
249
+ *
250
+ * The statements land in a bucket of their own, appended after every table statement, so a key
251
+ * never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
252
+ * last here puts them *first* on the way back: `drop table "posts"` with `comments` still
253
+ * referencing it is `2BP01`, a migration that cannot be rolled back at all.
254
+ */
255
+ function foreignKeyPlan(
256
+ entity: EntityDescriptionLike,
257
+ live: TableDescription | undefined,
258
+ constraints: Plan,
259
+ ): void {
260
+ const held = new Set((live?.foreignKeys ?? []).map(foreignKeyTarget));
261
+ for (const key of foreignKeysOf(entity)) {
262
+ if (held.has(foreignKeyTarget(key))) continue;
263
+ constraints.up.push(addForeignKey(entity.table, key));
264
+ constraints.down.push(dropForeignKey(entity.table, key.name));
265
+ }
266
+ }
267
+
170
268
  /**
171
269
  * Skipping an existing column by name alone missed the type moving under it: a table created
172
270
  * while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
@@ -189,6 +287,44 @@ function retypeColumn(
189
287
  plan.down.push(alter(recorded.dataType));
190
288
  }
191
289
 
290
+ /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
291
+ function indexShape(index: IndexDescriptionLike | IndexDescription): string {
292
+ return JSON.stringify([[...index.columns], index.unique, index.where, index.order ?? null]);
293
+ }
294
+
295
+ /**
296
+ * A same-named index whose definition moved is dropped and recreated, because Postgres has no
297
+ * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
298
+ * are all fixed at creation.
299
+ *
300
+ * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
301
+ * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
302
+ * migration and the database kept serving the old one. Both sides here are *generated* spellings
303
+ * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
304
+ * text difference in `where` is a real change and not a formatting one.
305
+ */
306
+ function redefineIndex(
307
+ table: string,
308
+ index: IndexDescriptionLike,
309
+ recorded: IndexDescription,
310
+ plan: Plan,
311
+ ): void {
312
+ if (indexShape(index) === indexShape(recorded)) return;
313
+ plan.up.push(`drop index "${index.name}";`, createIndex(table, index));
314
+ // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
315
+ // the recorded definition is what must land last, after the new one is dropped.
316
+ plan.down.push(
317
+ createIndex(table, {
318
+ name: recorded.name,
319
+ columns: recorded.columns,
320
+ unique: recorded.unique,
321
+ where: recorded.where,
322
+ order: recorded.order,
323
+ }),
324
+ `drop index "${index.name}";`,
325
+ );
326
+ }
327
+
192
328
  function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
193
329
  const existing = new Map(live.columns.map((column) => [column.name, column]));
194
330
  const added = new Set<string>();
@@ -213,15 +349,18 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
213
349
  plan.down.push(`alter table "${entity.table}" drop column "${column.column}";`);
214
350
  }
215
351
 
216
- const indexed = new Set(live.indexes.map((index) => index.name));
217
- for (const name of entity.indexes) {
218
- if (indexed.has(name)) continue;
219
- const index = parseIndexName(entity.table, name);
352
+ const indexed = new Map(live.indexes.map((index) => [index.name, index]));
353
+ for (const index of entity.indexes) {
354
+ const recorded = indexed.get(index.name);
355
+ if (recorded !== undefined) {
356
+ redefineIndex(entity.table, index, recorded, plan);
357
+ continue;
358
+ }
220
359
  // `added` only: an index over a column that was already there is implied by no clause this
221
360
  // migration emits, so it still needs a statement of its own.
222
- if (index.columns.length === 0 || impliedByColumnClause(entity, index, added)) continue;
361
+ if (impliedByColumnClause(entity, index, added)) continue;
223
362
  plan.up.push(createIndex(entity.table, index));
224
- plan.down.push(`drop index "${name}";`);
363
+ plan.down.push(`drop index "${index.name}";`);
225
364
  }
226
365
  }
227
366
 
@@ -242,6 +381,12 @@ export interface GeneratedMigration {
242
381
  readonly up: string;
243
382
  readonly down: string;
244
383
  readonly snapshot: SchemaDescription;
384
+ /**
385
+ * Whether `up` destroys data. Read off the generated SQL by the same classifier the gate runs,
386
+ * never assembled a second time from what the diff happened to push — one answer, so a migration
387
+ * cannot be written unmarked and then refused by `x verify` for lacking the mark.
388
+ */
389
+ readonly destructive: boolean;
245
390
  }
246
391
 
247
392
  export function migrationStamp(now: Date): string {
@@ -258,10 +403,13 @@ export function slugify(name: string): string {
258
403
  export function generateMigration(options: GenerateOptions): GeneratedMigration {
259
404
  const current = options.current ?? { tables: [] };
260
405
  const plan: Plan = { up: [], down: [] };
406
+ // Merged into `plan` once every table statement is in, never interleaved with them.
407
+ const constraints: Plan = { up: [], down: [] };
261
408
  const wanted = new Set(options.entities.map((entity) => entity.table));
262
409
 
263
410
  for (const entity of options.entities) {
264
411
  const live = findTable(current, entity.table);
412
+ foreignKeyPlan(entity, live, constraints);
265
413
  if (live === undefined) {
266
414
  plan.up.push(...createTable(entity));
267
415
  plan.down.push(`drop table "${entity.table}";`);
@@ -297,14 +445,19 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
297
445
  plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
298
446
  }
299
447
 
300
- const id = `${migrationStamp(options.now ?? new Date())}_${slugify(options.name)}`;
448
+ plan.up.push(...constraints.up);
449
+ plan.down.push(...constraints.down);
450
+
451
+ const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
452
+ const up = plan.up.join('\n');
301
453
  return {
302
454
  id,
303
455
  name: options.name,
304
456
  fileName: `migrations/${id}.sql`,
305
- up: plan.up.join('\n'),
457
+ up,
306
458
  // Reverse order: the last thing created is the first thing dropped.
307
459
  down: [...plan.down].reverse().join('\n'),
308
460
  snapshot: snapshotOf(options.entities),
461
+ destructive: isDestructive(up),
309
462
  };
310
463
  }