@ultimat3/db 1.2.0 → 3.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 +618 -0
- package/README.md +159 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +21 -5
- package/src/client.ts +259 -31
- package/src/destructive.ts +126 -0
- package/src/drift.ts +263 -14
- package/src/errors.ts +256 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +38 -11
- package/src/introspect.ts +34 -8
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +283 -64
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
|
@@ -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
|
-
|
|
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
|
+
// `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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
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)
|
|
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
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
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
|
|
217
|
-
for (const
|
|
218
|
-
|
|
219
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
}
|