@ultimat3/db 9.0.0 → 11.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 CHANGED
@@ -137,6 +137,16 @@ that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT can only be us
137
137
  blocks`. There is nothing to retry into, and an author who believes they hold a budget they do not
138
138
  is worse off than one who is told.
139
139
 
140
+ **`BEGIN` re-derives its isolation level from the closed set, `As of 2026-08-23`.** `BEGIN` takes
141
+ no parameters, so `beginStatement` is one of the two statements here built as TEXT — and the level
142
+ was `options.isolation.toUpperCase()` spliced into it. The TYPE is not the guard: the value reaches
143
+ `withTransaction` from an app's config, a JSON body or a CLI flag, and
144
+ `{ isolation: 'read committed; drop table x; --' }` became exactly that statement while a
145
+ non-string became an uncoded `TypeError` inside a template literal. `isolationMode` is a `switch`
146
+ over `IsolationLevel` whose `default` arm is `never` — a fourth level with no SQL beside it is a
147
+ type error, and anything else at runtime is `X_SQL_UNSAFE` (`isolationLevelInvalid`), the code
148
+ `branchNameInvalid` already uses for a value spliced into a statement.
149
+
140
150
  **The migration lock is polled, never waited on.** `pg_advisory_lock` blocks with no timeout, so a
141
151
  predecessor OOM-killed on a network partition kept its backend — and the lock — for hours while the
142
152
  new `ROLE=migrate` pod sat inside one statement printing nothing: `helm upgrade --wait` blocked on a
@@ -547,13 +557,35 @@ app: `create table "comments" (… references "posts" …)` before `create table
547
557
  `relation "posts" does not exist`. `down` had the mirror fault — `drop table "posts"` while
548
558
  `comments` still referenced it is `2BP01`. So `foreignKeyPlan` collects every key into a bucket of
549
559
  its own, merged into the plan **after** every table statement; `down` is reversed as a whole, so the
550
- drops pushed last there come out first. No topological sort and **no cycle error**: two tables
551
- referencing each other cannot be expressed inline in any order, and separate constraints need no
552
- order at all. The same call site answers the other half — a `references()` added to a column that
560
+ drops pushed last there come out first. No topological sort and **no cycle error** *for adds*: two
561
+ tables referencing each other cannot be expressed inline in any order, and separate constraints need
562
+ no order at all. Dropping is not symmetrical and does need one — the paragraph below. The same call site answers the other half — a `references()` added to a column that
553
563
  already exists now emits its `add constraint`, where before `up` came out **empty**, `x db gen`
554
564
  wrote no file, and `x verify`'s drift step stayed red forever with `x db gen "…"` as a fix that did
555
565
  nothing.
556
566
 
567
+ **Dropping a table has its own bucket, emitted BEFORE the table statements — the mirror image of
568
+ the one above, `As of 2026-08-23`.** `--allow-destructive` emitted a bare `drop table "authors";`
569
+ with every `alter table … drop constraint` appended AFTER it, so dropping a table another entity
570
+ `references()` was `2BP01 cannot drop table authors because other objects depend on it` — during
571
+ `ROLE=migrate` in the release phase, with the ledger recording nothing and a `down` of
572
+ `-- "<table>" cannot be restored`, i.e. nothing to reverse and a generated file to hand-edit. The
573
+ two-table case failed identically because drops came out **alphabetically**, which puts the parent
574
+ first. Two halves. `foreignKeyPlan` routes a key whose `referencedTable` is doomed into `preDrops`
575
+ instead of `constraints` — whether the entity still declares it or not, since a constraint cannot
576
+ outlive its target — and its `down` is a comment, because `add constraint` against a table no
577
+ `down` can restore is a rollback that cannot run. `drop-order.ts` orders the drops children-first
578
+ (a self-reference is not a blocker: `drop table` takes the table's own constraints with it) and
579
+ breaks a cycle between two doomed tables by dropping one inbound key first, which is the only
580
+ statement it emits. The `--allow-destructive` refusal is raised over that same ordered list, so
581
+ which table it names does not move with the alphabet.
582
+
583
+ **`foreignKeyPlan` lives in `foreign-key-plan.ts`, `As of 2026-08-23`** — split out of
584
+ `generate.ts` at the 500-line ceiling, along the seam it already drew: `generate.ts` assembles a
585
+ plan, `foreign-key-plan.ts` decides which bucket each key statement goes in, `foreign-key.ts`
586
+ writes the SQL. `Plan`, `foreignKeysOf` and `referenceParts` went with it because they are that
587
+ module's vocabulary; `snapshotOf` imports `foreignKeysOf` back, one direction only.
588
+
557
589
  **`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
558
590
  emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
559
591
  stayed on the database **and** the record denied one the catalog holds, which `compareForeignKeys`
@@ -647,7 +679,13 @@ statement_timeout` set moments earlier, so `select 1; set statement_timeout = 0`
647
679
  while `guards` went on reporting `timeout:5000ms`. `BEGIN READ ONLY` still held, so this was a
648
680
  defeated layer reported as an engaged one rather than a write — and a guard list that lies is worse
649
681
  than a guard list that is short. `statementsOf` is the package's one splitter, so a `;` inside a
650
- literal, a comment or a dollar-quoted body stays data.
682
+ literal, a comment or a dollar-quoted body stays data. **And the splice takes the splitter's
683
+ answer, `As of 2026-08-23`** — `statements[0]`, never the caller's text with a trailing `;` chopped
684
+ off it by a regex. That second answer only saw a `;` at the very END: `select 1; -- note` is one
685
+ statement to the splitter and does not end in `;`, so it reached the `DECLARE` whole and Postgres
686
+ answered `cannot insert multiple commands into a prepared statement`, uncoded, out of the path
687
+ whose whole job is bounding the read. The uncursored path still sends the caller's text
688
+ byte-for-byte, because it splices nothing.
651
689
 
652
690
  `readonly-role.ts` and `readonly-query.ts` are layers 1–2 of that tool's defence-in-depth: a
653
691
  `NOLOGIN` Postgres role (`ensureReadOnlyRole`) and a per-statement `BEGIN READ ONLY` + statement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "9.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "9.0.0"
34
+ "@ultimat3/core": "11.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -0,0 +1,61 @@
1
+ // Single responsibility: the order a set of tables can be dropped in, and the foreign keys that
2
+ // have to go first when no order exists. `generate.ts` writes the statements; this file decides
3
+ // which of them may run when, because `drop table` is refused (`2BP01`) while anything still
4
+ // points at the table.
5
+
6
+ import { dropForeignKey } from './foreign-key';
7
+ import type { TableDescription } from './introspect';
8
+
9
+ export interface DropOrder {
10
+ /** The tables, referencing before referenced. */
11
+ readonly tables: readonly TableDescription[];
12
+ /** `drop constraint` statements that must run BEFORE the first drop — cycles only. */
13
+ readonly constraints: readonly string[];
14
+ }
15
+
16
+ function referencesFrom(table: TableDescription, target: string): readonly string[] {
17
+ return table.foreignKeys
18
+ .filter((key) => key.referencedTable === target)
19
+ .map((key) => dropForeignKey(table.name, key.name));
20
+ }
21
+
22
+ /**
23
+ * Drop the children first: a table nothing still-to-be-dropped points at is always safe to drop,
24
+ * and removing it makes its own parents safe in turn. Alphabetical order — which is what
25
+ * `SchemaDescription` carries — puts `authors` before `posts`, i.e. the parent first, which is the
26
+ * one order Postgres refuses.
27
+ *
28
+ * A self-reference is never a blocker: `drop table` takes the table's OWN constraints with it, so
29
+ * a tree table needs no `drop constraint` of its own.
30
+ *
31
+ * A cycle between two doomed tables has no safe order at all, so one of them is chosen and the
32
+ * keys pointing at it are dropped first. That is the only case that emits a statement here — every
33
+ * other inbound key belongs to a table that SURVIVES, which `generate.ts` handles beside the entity
34
+ * that still owns the column.
35
+ */
36
+ export function dropOrder(dropped: readonly TableDescription[]): DropOrder {
37
+ const remaining = [...dropped];
38
+ const tables: TableDescription[] = [];
39
+ const constraints: string[] = [];
40
+ while (remaining.length > 0) {
41
+ const free = remaining.findIndex(
42
+ (table) =>
43
+ !remaining.some(
44
+ (other) =>
45
+ other.name !== table.name &&
46
+ other.foreignKeys.some((key) => key.referencedTable === table.name),
47
+ ),
48
+ );
49
+ const index = free === -1 ? 0 : free;
50
+ const next = remaining[index] as TableDescription;
51
+ if (free === -1) {
52
+ for (const other of remaining) {
53
+ if (other.name === next.name) continue;
54
+ constraints.push(...referencesFrom(other, next.name));
55
+ }
56
+ }
57
+ tables.push(next);
58
+ remaining.splice(index, 1);
59
+ }
60
+ return { tables, constraints };
61
+ }
package/src/errors.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  // fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
3
3
  // pinned by the framework contract, so change its strings only with the contract.
4
4
 
5
- import { registerErrorCodes, renderThrowable, stringField, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ describeValue,
7
+ registerErrorCodes,
8
+ renderThrowable,
9
+ stringField,
10
+ UltimateError,
11
+ } from '@ultimat3/core';
6
12
  import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
7
13
  import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
8
14
 
@@ -97,7 +103,6 @@ export class DbError extends UltimateError {
97
103
  code: init.code,
98
104
  cause: init.cause,
99
105
  fix: init.fix,
100
- docs: `https://ultimate.dev/errors/${init.code}`,
101
106
  meta: init.meta,
102
107
  sourceError: init.sourceError,
103
108
  });
@@ -385,6 +390,23 @@ export const branchNameInvalid = (branch: string): DbError =>
385
390
  meta: { branch },
386
391
  });
387
392
 
393
+ /**
394
+ * An isolation level that is not one of the three. `X_SQL_UNSAFE` for the reason
395
+ * `branchNameInvalid` uses it: `BEGIN` takes no parameters, so the level is SPLICED into the
396
+ * statement text — `withTransaction(fn, { isolation: fromConfig })` with an operand TypeScript
397
+ * never saw is an injection, not a typo.
398
+ *
399
+ * `describeValue`, never the value: this cause is folded into a problem document and a log line,
400
+ * and an operand that arrived from a request body has no key left to redact once it is baked into
401
+ * a message. The three legal spellings are in the `fix:`, which is what the caller needs.
402
+ */
403
+ export const isolationLevelInvalid = (received: unknown): DbError =>
404
+ new DbError({
405
+ code: 'X_SQL_UNSAFE',
406
+ cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
407
+ fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
408
+ });
409
+
388
410
  export const dbNotImplemented = (feature: string, fix: string): DbError =>
389
411
  new DbError({
390
412
  code: 'X_NOT_IMPLEMENTED',
@@ -0,0 +1,146 @@
1
+ // Single responsibility: which foreign keys a migration must add or drop, and into which of the
2
+ // two buckets each statement goes — `constraints` runs AFTER the table statements, `preDrops`
3
+ // BEFORE them. `generate.ts` assembles the plan; `foreign-key.ts` writes the SQL.
4
+
5
+ import type { EntityDescriptionLike } from './entity-shape';
6
+ import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
7
+ import type { ForeignKeyDescription, TableDescription } from './introspect';
8
+
9
+ /** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
10
+ export interface Plan {
11
+ readonly up: string[];
12
+ readonly down: string[];
13
+ }
14
+
15
+ /**
16
+ * The two ends of a `references()`, which entity renders as `"<table>.<column>"`. Read once: the
17
+ * clause that writes the constraint and the snapshot that records it must not disagree about what
18
+ * it points at, or drift reports a key the database holds exactly as declared.
19
+ */
20
+ export function referenceParts(references: string): readonly [string, string] {
21
+ const [table = references, column = 'id'] = references.split('.');
22
+ return [table, column];
23
+ }
24
+
25
+ /**
26
+ * The keys this entity declares, recorded so drift can see one dropped by hand. Recording
27
+ * `foreignKeys: []` while the same run emitted a constraint was a snapshot claiming a constraint
28
+ * does not exist that the migration beside it creates, and `compareTable` had nothing to compare —
29
+ * a key dropped on the database was invisible to every check the framework runs.
30
+ *
31
+ * The name is `<table>_<column>_fkey` — what Postgres would have called an inline `references`
32
+ * clause — and `addForeignKey` now writes it out, so the snapshot records a name the migration
33
+ * beside it chose rather than one it guessed. It is still *not* what drift matches on: see
34
+ * `compareForeignKeys` in `drift.ts`.
35
+ *
36
+ * `onDelete` is the column's own, and `addForeignKey` spells it: a rule recorded here while no
37
+ * clause declared one would be a claim about the database that is not true, which is what it was
38
+ * until the clause learned to write it out.
39
+ */
40
+ export function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
41
+ return entity.columns
42
+ .filter((column) => column.references !== null)
43
+ .map((column): ForeignKeyDescription => {
44
+ const [table, key] = referenceParts(column.references ?? '');
45
+ return {
46
+ name: `${entity.table}_${column.column}_fkey`,
47
+ columns: [column.column],
48
+ referencedTable: table,
49
+ referencedColumns: [key],
50
+ onDelete: column.onDelete ?? null,
51
+ };
52
+ })
53
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
54
+ }
55
+
56
+ /**
57
+ * Every foreign key the entity declares that the previous snapshot does not already record, as its
58
+ * own `add constraint`. One call site for both cases the generator has — a table being created and
59
+ * a `references()` added to a column that already exists — because they are one question: which of
60
+ * this entity's keys does the database not hold yet.
61
+ *
62
+ * The statements land in a bucket of their own, appended after every table statement, so a key
63
+ * never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
64
+ * last here puts them *first* on the way back: `drop table "posts"` with `comments` still
65
+ * referencing it is `2BP01`, a migration that cannot be rolled back at all.
66
+ *
67
+ * The mirror image of that asymmetry is `plans.preDrops`, which is emitted BEFORE the table
68
+ * statements: a key pointing at a table this migration DROPS has to go first, or the drop is
69
+ * `2BP01` on the way forward. Its `down` is a comment for the same reason the table's own is —
70
+ * `add constraint` against a table no `down` can restore is a rollback that cannot run.
71
+ *
72
+ * Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
73
+ * while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
74
+ * database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
75
+ * declared side, so no drift check could ever see it. Not parity with a removed index either: that
76
+ * leaves the snapshot correct by omission. The drop names the constraint the previous snapshot
77
+ * recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
78
+ * `42704` under the generated spelling.
79
+ */
80
+ export function foreignKeyPlan(
81
+ entity: EntityDescriptionLike,
82
+ live: TableDescription | undefined,
83
+ plans: ConstraintPlans,
84
+ ): void {
85
+ const { constraints, preDrops, doomed } = plans;
86
+ const wanted = foreignKeysOf(entity);
87
+ const held = new Map((live?.foreignKeys ?? []).map((key) => [foreignKeyTarget(key), key]));
88
+ for (const key of wanted) {
89
+ const recorded = held.get(foreignKeyTarget(key));
90
+ if (doomed.has(key.referencedTable)) {
91
+ // Still declared, but its target is going away this migration. The constraint cannot outlive
92
+ // the table, so it goes first — and no `add constraint` is written for one that never was.
93
+ if (recorded !== undefined)
94
+ unrestorableDrop(entity.table, recorded.name, key.referencedTable, preDrops);
95
+ continue;
96
+ }
97
+ if (recorded === undefined) {
98
+ constraints.up.push(addForeignKey(entity.table, key));
99
+ constraints.down.push(dropForeignKey(entity.table, key.name));
100
+ continue;
101
+ }
102
+ // The rule is not part of a key's identity, so the same key under a new one is a rebuild —
103
+ // Postgres has no `alter constraint` for it, the same reason `redefineIndex` recreates.
104
+ if (onDeleteRule(recorded.onDelete) === onDeleteRule(key.onDelete)) continue;
105
+ constraints.up.push(
106
+ dropForeignKey(entity.table, recorded.name),
107
+ addForeignKey(entity.table, key),
108
+ );
109
+ // Pushed forwards and read backwards, like `redefineIndex`: `down` is reversed at assembly.
110
+ constraints.down.push(
111
+ addForeignKey(entity.table, recorded),
112
+ dropForeignKey(entity.table, key.name),
113
+ );
114
+ }
115
+ const declared = new Set(wanted.map(foreignKeyTarget));
116
+ const columns = new Set(entity.columns.map((column) => column.column));
117
+ for (const key of live?.foreignKeys ?? []) {
118
+ if (declared.has(foreignKeyTarget(key))) continue;
119
+ // `drop column` takes the constraint with it, so a `drop constraint` after that statement is
120
+ // `42704` on a constraint that is already gone.
121
+ if (!key.columns.every((column) => columns.has(column))) continue;
122
+ if (doomed.has(key.referencedTable)) {
123
+ unrestorableDrop(entity.table, key.name, key.referencedTable, preDrops);
124
+ continue;
125
+ }
126
+ constraints.up.push(dropForeignKey(entity.table, key.name));
127
+ constraints.down.push(addForeignKey(entity.table, key));
128
+ }
129
+ }
130
+
131
+ export interface ConstraintPlans {
132
+ /** `add`/`drop` for keys between tables that survive — appended AFTER the table statements. */
133
+ readonly constraints: Plan;
134
+ /** Keys pointing at a table this migration drops — emitted BEFORE the table statements. */
135
+ readonly preDrops: Plan;
136
+ /** The tables this migration drops, by name. */
137
+ readonly doomed: ReadonlySet<string>;
138
+ }
139
+
140
+ /** A key whose target is being dropped: gone on the way up, a note on the way back. */
141
+ function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
142
+ preDrops.up.push(dropForeignKey(table, constraint));
143
+ preDrops.down.push(
144
+ `-- constraint "${constraint}" on "${table}" cannot be restored; "${target}" is gone`,
145
+ );
146
+ }
@@ -5,14 +5,22 @@
5
5
  import { assert } from '@ultimat3/core';
6
6
  import type { ForeignKeyDescription } from './introspect';
7
7
 
8
- /** `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name. */
9
- const CATALOG_RULES: Readonly<Record<string, string>> = {
10
- a: 'no action',
11
- c: 'cascade',
12
- r: 'restrict',
13
- n: 'set null',
14
- d: 'set default',
15
- };
8
+ /**
9
+ * `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
10
+ *
11
+ * A `Map`, because `raw` is a catalog string on every read: an object literal answered
12
+ * `CATALOG_RULES['constructor']` with the `Object` FUNCTION, which left this `string | null`
13
+ * function returning one into `compareForeignKeys`.
14
+ */
15
+ const CATALOG_RULES: ReadonlyMap<string, string> = new Map(
16
+ Object.entries({
17
+ a: 'no action',
18
+ c: 'cascade',
19
+ r: 'restrict',
20
+ n: 'set null',
21
+ d: 'set default',
22
+ }),
23
+ );
16
24
 
17
25
  /**
18
26
  * One `on delete` vocabulary for both sides. The catalog spells the rule as a single character and
@@ -25,7 +33,7 @@ const CATALOG_RULES: Readonly<Record<string, string>> = {
25
33
  */
26
34
  export function onDeleteRule(raw: string | null): string | null {
27
35
  if (raw === null) return null;
28
- const named = CATALOG_RULES[raw] ?? raw.toLowerCase();
36
+ const named = CATALOG_RULES.get(raw) ?? raw.toLowerCase();
29
37
  return named === 'no action' ? null : named;
30
38
  }
31
39
 
package/src/generate.ts CHANGED
@@ -5,16 +5,16 @@
5
5
 
6
6
  import { assert, systemClock } from '@ultimat3/core';
7
7
  import { isDestructive } from './destructive';
8
+ import { dropOrder } from './drop-order';
8
9
  import type {
9
10
  ColumnDescriptionLike,
10
11
  EntityDescriptionLike,
11
12
  IndexDescriptionLike,
12
13
  } from './entity-shape';
13
14
  import { migrationIrreversible } from './errors';
14
- import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
15
+ import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
15
16
  import {
16
17
  type ColumnDescription,
17
- type ForeignKeyDescription,
18
18
  findTable,
19
19
  type IndexDescription,
20
20
  type SchemaDescription,
@@ -52,16 +52,6 @@ function defaultExpression(column: ColumnDescriptionLike): string | null {
52
52
  return null;
53
53
  }
54
54
 
55
- /**
56
- * The two ends of a `references()`, which entity renders as `"<table>.<column>"`. Read once: the
57
- * clause that writes the constraint and the snapshot that records it must not disagree about what
58
- * it points at, or drift reports a key the database holds exactly as declared.
59
- */
60
- function referenceParts(references: string): readonly [string, string] {
61
- const [table = references, column = 'id'] = references.split('.');
62
- return [table, column];
63
- }
64
-
65
55
  function columnClause(column: ColumnDescriptionLike): string {
66
56
  const parts = [`"${column.column}"`, sqlType(column.kind)];
67
57
  const expression = defaultExpression(column);
@@ -103,37 +93,6 @@ function impliedByColumnClause(
103
93
  return column !== undefined && column.unique && !column.primaryKey && added.has(only);
104
94
  }
105
95
 
106
- /**
107
- * The keys this entity declares, recorded so drift can see one dropped by hand. Recording
108
- * `foreignKeys: []` while the same run emitted a constraint was a snapshot claiming a constraint
109
- * does not exist that the migration beside it creates, and `compareTable` had nothing to compare —
110
- * a key dropped on the database was invisible to every check the framework runs.
111
- *
112
- * The name is `<table>_<column>_fkey` — what Postgres would have called an inline `references`
113
- * clause — and `addForeignKey` now writes it out, so the snapshot records a name the migration
114
- * beside it chose rather than one it guessed. It is still *not* what drift matches on: see
115
- * `compareForeignKeys` in `drift.ts`.
116
- *
117
- * `onDelete` is the column's own, and `addForeignKey` spells it: a rule recorded here while no
118
- * clause declared one would be a claim about the database that is not true, which is what it was
119
- * until the clause learned to write it out.
120
- */
121
- function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
122
- return entity.columns
123
- .filter((column) => column.references !== null)
124
- .map((column): ForeignKeyDescription => {
125
- const [table, key] = referenceParts(column.references ?? '');
126
- return {
127
- name: `${entity.table}_${column.column}_fkey`,
128
- columns: [column.column],
129
- referencedTable: table,
130
- referencedColumns: [key],
131
- onDelete: column.onDelete ?? null,
132
- };
133
- })
134
- .sort((a, b) => (a.name < b.name ? -1 : 1));
135
- }
136
-
137
96
  export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
138
97
  const tables = [...entities]
139
98
  .sort((a, b) => (a.table < b.table ? -1 : 1))
@@ -202,69 +161,6 @@ function createIndex(table: string, index: IndexDescriptionLike): string {
202
161
  return `${kind} "${index.name}" on "${table}" (${columns})${predicate};`;
203
162
  }
204
163
 
205
- interface Plan {
206
- readonly up: string[];
207
- readonly down: string[];
208
- }
209
-
210
- /**
211
- * Every foreign key the entity declares that the previous snapshot does not already record, as its
212
- * own `add constraint`. One call site for both cases the generator has — a table being created and
213
- * a `references()` added to a column that already exists — because they are one question: which of
214
- * this entity's keys does the database not hold yet.
215
- *
216
- * The statements land in a bucket of their own, appended after every table statement, so a key
217
- * never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
218
- * last here puts them *first* on the way back: `drop table "posts"` with `comments` still
219
- * referencing it is `2BP01`, a migration that cannot be rolled back at all.
220
- *
221
- * Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
222
- * while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
223
- * database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
224
- * declared side, so no drift check could ever see it. Not parity with a removed index either: that
225
- * leaves the snapshot correct by omission. The drop names the constraint the previous snapshot
226
- * recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
227
- * `42704` under the generated spelling.
228
- */
229
- function foreignKeyPlan(
230
- entity: EntityDescriptionLike,
231
- live: TableDescription | undefined,
232
- constraints: Plan,
233
- ): void {
234
- const wanted = foreignKeysOf(entity);
235
- const held = new Map((live?.foreignKeys ?? []).map((key) => [foreignKeyTarget(key), key]));
236
- for (const key of wanted) {
237
- const recorded = held.get(foreignKeyTarget(key));
238
- if (recorded === undefined) {
239
- constraints.up.push(addForeignKey(entity.table, key));
240
- constraints.down.push(dropForeignKey(entity.table, key.name));
241
- continue;
242
- }
243
- // The rule is not part of a key's identity, so the same key under a new one is a rebuild —
244
- // Postgres has no `alter constraint` for it, the same reason `redefineIndex` recreates.
245
- if (onDeleteRule(recorded.onDelete) === onDeleteRule(key.onDelete)) continue;
246
- constraints.up.push(
247
- dropForeignKey(entity.table, recorded.name),
248
- addForeignKey(entity.table, key),
249
- );
250
- // Pushed forwards and read backwards, like `redefineIndex`: `down` is reversed at assembly.
251
- constraints.down.push(
252
- addForeignKey(entity.table, recorded),
253
- dropForeignKey(entity.table, key.name),
254
- );
255
- }
256
- const declared = new Set(wanted.map(foreignKeyTarget));
257
- const columns = new Set(entity.columns.map((column) => column.column));
258
- for (const key of live?.foreignKeys ?? []) {
259
- if (declared.has(foreignKeyTarget(key))) continue;
260
- // `drop column` takes the constraint with it, so a `drop constraint` after that statement is
261
- // `42704` on a constraint that is already gone.
262
- if (!key.columns.every((column) => columns.has(column))) continue;
263
- constraints.up.push(dropForeignKey(entity.table, key.name));
264
- constraints.down.push(addForeignKey(entity.table, key));
265
- }
266
- }
267
-
268
164
  /**
269
165
  * Skipping an existing column by name alone missed the type moving under it: a table created
270
166
  * while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
@@ -405,11 +301,19 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
405
301
  const plan: Plan = { up: [], down: [] };
406
302
  // Merged into `plan` once every table statement is in, never interleaved with them.
407
303
  const constraints: Plan = { up: [], down: [] };
304
+ // Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
305
+ // migration drops makes that `drop table` `2BP01`.
306
+ const preDrops: Plan = { up: [], down: [] };
408
307
  const wanted = new Set(options.entities.map((entity) => entity.table));
409
308
 
309
+ const doomed = new Set(
310
+ current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
311
+ );
312
+ const plans: ConstraintPlans = { constraints, preDrops, doomed };
313
+
410
314
  for (const entity of options.entities) {
411
315
  const live = findTable(current, entity.table);
412
- foreignKeyPlan(entity, live, constraints);
316
+ foreignKeyPlan(entity, live, plans);
413
317
  if (live === undefined) {
414
318
  plan.up.push(...createTable(entity));
415
319
  plan.down.push(`drop table "${entity.table}";`);
@@ -433,14 +337,18 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
433
337
  }
434
338
  }
435
339
 
436
- for (const table of current.tables) {
437
- if (wanted.has(table.name)) continue;
340
+ const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
341
+ for (const table of order.tables) {
438
342
  if (options.allowDestructive !== true) {
439
343
  throw migrationIrreversible(
440
344
  `dropping table "${table.name}" discards every row and cannot be undone`,
441
345
  `x db gen "${options.name}" --allow-destructive # or delete the entity in two releases`,
442
346
  );
443
347
  }
348
+ }
349
+ plan.up.push(...preDrops.up, ...order.constraints);
350
+ plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
351
+ for (const table of order.tables) {
444
352
  plan.up.push(`drop table "${table.name}";`);
445
353
  plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
446
354
  }
@@ -128,7 +128,15 @@ export async function readOnlyQuery<T>(
128
128
  guards.push(`role:${options.role}`);
129
129
  }
130
130
 
131
- const rows = await readRows<T>(connection, statement, fetchCount(options.maxRows), guards);
131
+ // Two texts, deliberately: the caller's own, sent verbatim when nothing is spliced, and the
132
+ // one COMMAND `statementsOf` cut out of it, which is the only text a `DECLARE` may carry.
133
+ const rows = await readRows<T>(
134
+ connection,
135
+ statement,
136
+ statements[0] ?? statement,
137
+ fetchCount(options.maxRows),
138
+ guards,
139
+ );
132
140
  await connection.execute(raw('ROLLBACK'));
133
141
  return { rows, guards };
134
142
  } catch (error) {
@@ -151,16 +159,25 @@ export async function readOnlyQuery<T>(
151
159
  */
152
160
  async function readRows<T>(
153
161
  connection: DbClient,
162
+ /** What the caller wrote. Sent byte-for-byte on the uncursored path, which splices nothing. */
154
163
  statement: string,
164
+ /**
165
+ * The one command `statementsOf` cut out of it — what may be spliced.
166
+ *
167
+ * This was `statement.trim().replace(/;\s*$/, '')`, a second answer to "where does the command
168
+ * end" that only saw a `;` at the very END of the text: `select 1; -- note` is ONE statement to
169
+ * the splitter (a chunk of pure noise is not a statement), so it passed the one-statement gate
170
+ * and reached the splice whole, as `DECLARE … CURSOR FOR select 1; -- note` — two commands, and
171
+ * `cannot insert multiple commands into a prepared statement` out of the driver with no code
172
+ * and no `fix:`. The splitter is the package's one answer, and this is now its only reader.
173
+ */
174
+ command: string,
155
175
  fetch: number | undefined,
156
176
  guards: string[],
157
177
  ): Promise<readonly T[]> {
158
- if (fetch === undefined || !cursorable(statement)) return connection.query<T>(raw(statement));
178
+ if (fetch === undefined || !cursorable(command)) return connection.query<T>(raw(statement));
159
179
 
160
- // A trailing `;` would close `DECLARE` before its query and turn one statement into two. An
161
- // EMBEDDED one is refused up in `readOnlyQuery`, before the transaction opens.
162
- const query = statement.trim().replace(/;\s*$/, '');
163
- await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${query}`));
180
+ await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${command}`));
164
181
  const rows = await connection.query<T>(raw(`FETCH FORWARD ${fetch} FROM ${CURSOR_NAME}`));
165
182
  guards.push(`fetch:${fetch} rows`);
166
183
  return rows;
package/src/sql.ts CHANGED
@@ -135,6 +135,13 @@ export function identifier(name: string): SqlFragment {
135
135
  * A quoted string literal. Postgres utility statements (`CREATE DATABASE`, `COMMENT ON`) reject
136
136
  * bound parameters, so this is the only place a value may be inlined — and it escapes quotes.
137
137
  * Never reach for it in a query: `sql` binds parameters there.
138
+ *
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.
138
145
  */
139
146
  export function literal(value: string): SqlFragment {
140
147
  return raw(`'${value.replaceAll("'", "''")}'`);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { assert, asyncContext, nanoid } from '@ultimat3/core';
7
7
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
8
- import { serializationExhausted } from './errors';
8
+ import { isolationLevelInvalid, serializationExhausted } from './errors';
9
9
  import { raw, type SqlFragment } from './sql';
10
10
  import { isRetryableState } from './sqlstate';
11
11
 
@@ -102,11 +102,36 @@ export function inLiveTx(): boolean {
102
102
  return storage.get()?.live.value === true;
103
103
  }
104
104
 
105
+ /**
106
+ * The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
107
+ * value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
108
+ * no parameters, so this is one of the two statements here built as text, and a level spliced into
109
+ * it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
110
+ * value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
111
+ * `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
112
+ * non-string became an uncoded `TypeError` inside a template literal.
113
+ *
114
+ * The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
115
+ * is a type error here rather than a refusal at runtime.
116
+ */
117
+ const isolationMode = (declared: IsolationLevel): string => {
118
+ switch (declared) {
119
+ case 'read committed':
120
+ return 'ISOLATION LEVEL READ COMMITTED';
121
+ case 'repeatable read':
122
+ return 'ISOLATION LEVEL REPEATABLE READ';
123
+ case 'serializable':
124
+ return 'ISOLATION LEVEL SERIALIZABLE';
125
+ default: {
126
+ const unhandled: never = declared;
127
+ throw isolationLevelInvalid(unhandled);
128
+ }
129
+ }
130
+ };
131
+
105
132
  export function beginStatement(options: TransactionOptions): string {
106
133
  const modes: string[] = [];
107
- if (options.isolation !== undefined) {
108
- modes.push(`ISOLATION LEVEL ${options.isolation.toUpperCase()}`);
109
- }
134
+ if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
110
135
  if (options.readOnly === true) modes.push('READ ONLY');
111
136
  if (options.deferrable === true) modes.push('DEFERRABLE');
112
137
  return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;