@ultimat3/db 22.15.0 → 24.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 +91 -56
- package/README.md +121 -6
- package/package.json +5 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +116 -0
- package/src/catalog-objects.ts +229 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +174 -0
- package/src/client.ts +57 -9
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +213 -60
- package/src/drift.ts +19 -13
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +8 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +9 -3
- package/src/introspect-catalog.ts +171 -0
- package/src/introspect.ts +45 -8
- package/src/listen.ts +62 -0
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +162 -0
- package/src/pglite-branch.ts +6 -6
- package/src/pglite-extensions.ts +112 -0
- package/src/pglite-package.ts +11 -0
- package/src/pglite-snapshot.ts +121 -0
- package/src/pglite.ts +146 -11
- package/src/pool-gauge.ts +70 -0
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +75 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +33 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
package/src/pglite.ts
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
// that only ever talks to a managed Postgres must not carry 26 MB of WASM it will never load.
|
|
5
5
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
|
+
import { refuseUnsendable } from './bound-parameters';
|
|
7
8
|
import type { DbClient, DbConnection, ReservableClient } from './client';
|
|
9
|
+
import { refuseRolledBackCommit } from './commit-tag';
|
|
8
10
|
import { DbError, driverError } from './errors';
|
|
9
11
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
12
|
+
import {
|
|
13
|
+
assertListenChannel,
|
|
14
|
+
type DbSubscription,
|
|
15
|
+
type ListeningClient,
|
|
16
|
+
listenUnsupported,
|
|
17
|
+
} from './listen';
|
|
10
18
|
import { statementObserver } from './observe';
|
|
11
19
|
import { PGLITE_INSTANT_PARSERS } from './pg-instant';
|
|
20
|
+
import { linkPgliteExtensions, type PgliteExtensionLoader } from './pglite-extensions';
|
|
21
|
+
import { PGLITE_PACKAGE } from './pglite-package';
|
|
22
|
+
import {
|
|
23
|
+
discardSnapshot,
|
|
24
|
+
pgliteVersion,
|
|
25
|
+
readSnapshot,
|
|
26
|
+
snapshotFile,
|
|
27
|
+
snapshotKey,
|
|
28
|
+
writeSnapshot,
|
|
29
|
+
} from './pglite-snapshot';
|
|
12
30
|
import { createTurnQueue } from './pglite-turns';
|
|
13
31
|
import type { SqlFragment } from './sql';
|
|
14
32
|
import { statementExcerpt } from './statement-excerpt';
|
|
@@ -20,12 +38,18 @@ export interface PgliteResult {
|
|
|
20
38
|
readonly rows: readonly unknown[];
|
|
21
39
|
/** Postgres' command-tag count — the only truthful answer for INSERT/UPDATE/DELETE. */
|
|
22
40
|
readonly affectedRows?: number | undefined;
|
|
41
|
+
/** The command tag's verb. `ROLLBACK` in answer to a `COMMIT` is how an aborted one reads. */
|
|
42
|
+
readonly command?: string | undefined;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/** The slice of PGlite we need. Declared structurally — this package has no dependencies. */
|
|
26
46
|
export interface PgliteDriver {
|
|
27
47
|
query(text: string, values?: readonly unknown[]): Promise<PgliteResult>;
|
|
28
48
|
exec?(text: string): Promise<unknown>;
|
|
49
|
+
/** `LISTEN` on the one session there is. Resolves to the unsubscribe. */
|
|
50
|
+
listen?(channel: string, callback: (payload: string) => void): Promise<() => Promise<void>>;
|
|
51
|
+
/** The whole data directory as one tarball — what `pglite-snapshot.ts` caches. */
|
|
52
|
+
dumpDataDir?(compression: 'none'): Promise<Blob>;
|
|
29
53
|
close(): Promise<void>;
|
|
30
54
|
}
|
|
31
55
|
|
|
@@ -33,7 +57,13 @@ export interface PgliteDriver {
|
|
|
33
57
|
export interface PgliteModule {
|
|
34
58
|
readonly PGlite: new (
|
|
35
59
|
dataDir?: string,
|
|
36
|
-
options?: {
|
|
60
|
+
options?: {
|
|
61
|
+
readonly parsers?: Readonly<Record<number, (text: string) => unknown>>;
|
|
62
|
+
/** Keyed by the bundle's own export name; each value is opaque to this package. */
|
|
63
|
+
readonly extensions?: Readonly<Record<string, unknown>>;
|
|
64
|
+
/** A `dumpDataDir()` tarball to start from instead of running `initdb`. */
|
|
65
|
+
readonly loadDataDir?: Blob;
|
|
66
|
+
},
|
|
37
67
|
) => PgliteDriver;
|
|
38
68
|
}
|
|
39
69
|
|
|
@@ -47,6 +77,24 @@ export interface PgliteOptions {
|
|
|
47
77
|
readonly driver?: PgliteDriver | undefined;
|
|
48
78
|
/** Swap the module loader. Tests use it; nothing in the framework does. */
|
|
49
79
|
readonly load?: PgliteLoader | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Extensions to make available to `create extension`, by their Postgres names (`citext`,
|
|
82
|
+
* `uuid-ossp`). PGlite links an extension only when it is handed over at boot, so a migration
|
|
83
|
+
* that creates one fails on an instance that was not told. A function, for a caller whose list
|
|
84
|
+
* is read from disk: a client is constructed synchronously and boots on its first statement.
|
|
85
|
+
* A name PGlite ships no bundle for is skipped here and refused by the server at
|
|
86
|
+
* `create extension`, in its own words (`pglite-extensions.ts`).
|
|
87
|
+
*/
|
|
88
|
+
readonly extensions?: readonly string[] | (() => Promise<readonly string[]>) | undefined;
|
|
89
|
+
/** Swap the extension bundle loader, by module specifier. Tests use it. */
|
|
90
|
+
readonly loadExtension?: PgliteExtensionLoader | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* A directory to keep the post-`initdb` snapshot in, so an in-memory boot is a restore
|
|
93
|
+
* (`pglite-snapshot.ts`). Read only for `memory://`: a directory on disk is its own snapshot.
|
|
94
|
+
*/
|
|
95
|
+
readonly snapshotDir?: string | undefined;
|
|
96
|
+
/** Swap how the installed PGlite version is read. Tests use it; `undefined` disables caching. */
|
|
97
|
+
readonly version?: (() => Promise<string | undefined>) | undefined;
|
|
50
98
|
}
|
|
51
99
|
|
|
52
100
|
export const PGLITE_FIX =
|
|
@@ -57,13 +105,9 @@ export const PGLITE_MEMORY = 'memory://';
|
|
|
57
105
|
|
|
58
106
|
const PGLITE_URL = 'pglite://';
|
|
59
107
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* diagnostic that spelled the package name a second time is a diagnostic that can name the wrong
|
|
64
|
-
* one after a rename.
|
|
65
|
-
*/
|
|
66
|
-
export const PGLITE_PACKAGE = '@electric-sql/pglite';
|
|
108
|
+
// Re-exported: `src/index.ts` and `x doctor` read it from here, and the constant itself lives in
|
|
109
|
+
// a leaf so the extension linker and the snapshot cache can share it without a cycle.
|
|
110
|
+
export { PGLITE_PACKAGE };
|
|
67
111
|
|
|
68
112
|
/**
|
|
69
113
|
* Why there is no embedded database when that specifier does not resolve. One sentence, shared:
|
|
@@ -99,6 +143,38 @@ function pgliteConstructor(loaded: unknown): PgliteModule['PGlite'] {
|
|
|
99
143
|
return exported as PgliteModule['PGlite'];
|
|
100
144
|
}
|
|
101
145
|
|
|
146
|
+
type PgliteConstructor = PgliteModule['PGlite'];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A scratch boot from the cache, or `undefined` when there is nothing sound to restore from. The
|
|
150
|
+
* restored instance is asked one statement before it is believed: a tarball can verify against
|
|
151
|
+
* its checksum and still be one this build cannot open, and that must cost a rebuild, never a
|
|
152
|
+
* failed command.
|
|
153
|
+
*/
|
|
154
|
+
async function restore(
|
|
155
|
+
PGlite: PgliteConstructor,
|
|
156
|
+
base: { readonly extensions?: Readonly<Record<string, unknown>> },
|
|
157
|
+
file: string,
|
|
158
|
+
key: string,
|
|
159
|
+
): Promise<PgliteDriver | undefined> {
|
|
160
|
+
const snapshot = await readSnapshot(file, key);
|
|
161
|
+
if (snapshot === undefined) return undefined;
|
|
162
|
+
let driver: PgliteDriver | undefined;
|
|
163
|
+
try {
|
|
164
|
+
driver = new PGlite(PGLITE_MEMORY, {
|
|
165
|
+
parsers: PGLITE_INSTANT_PARSERS,
|
|
166
|
+
...base,
|
|
167
|
+
loadDataDir: snapshot,
|
|
168
|
+
});
|
|
169
|
+
await driver.query('select 1');
|
|
170
|
+
return driver;
|
|
171
|
+
} catch {
|
|
172
|
+
await driver?.close().catch(() => undefined);
|
|
173
|
+
await discardSnapshot(file, key);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
102
178
|
/** Boots one embedded Postgres. Costs seconds — `createPgliteClient` calls it exactly once. */
|
|
103
179
|
export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<PgliteDriver> {
|
|
104
180
|
if (options.driver !== undefined) return options.driver;
|
|
@@ -110,13 +186,45 @@ export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<Pgl
|
|
|
110
186
|
throw missing(PGLITE_MISSING, error);
|
|
111
187
|
}
|
|
112
188
|
const PGlite = pgliteConstructor(loaded);
|
|
189
|
+
const names =
|
|
190
|
+
typeof options.extensions === 'function' ? await options.extensions() : options.extensions;
|
|
191
|
+
const { linked } = await linkPgliteExtensions(names ?? [], options.loadExtension);
|
|
192
|
+
// Absent, never `{}`: every boot that names no extension hands PGlite exactly what it did.
|
|
193
|
+
const base = Object.keys(linked).length === 0 ? {} : { extensions: linked };
|
|
194
|
+
const version =
|
|
195
|
+
options.snapshotDir === undefined || dataDir !== PGLITE_MEMORY
|
|
196
|
+
? undefined
|
|
197
|
+
: await (options.version ?? pgliteVersion)();
|
|
198
|
+
const key = version === undefined ? undefined : snapshotKey(version);
|
|
199
|
+
const file =
|
|
200
|
+
key === undefined || options.snapshotDir === undefined
|
|
201
|
+
? undefined
|
|
202
|
+
: snapshotFile(options.snapshotDir, key);
|
|
203
|
+
if (file !== undefined && key !== undefined) {
|
|
204
|
+
const restored = await restore(PGlite, base, file, key);
|
|
205
|
+
if (restored !== undefined) return restored;
|
|
206
|
+
}
|
|
207
|
+
let driver: PgliteDriver;
|
|
113
208
|
try {
|
|
114
209
|
// `pg-instant.ts` reads every timestamp: PGlite's own parser took year 0099 for 1999 and an
|
|
115
210
|
// offset with seconds for Invalid Date under any session zone that is not UTC.
|
|
116
|
-
|
|
211
|
+
driver = new PGlite(dataDir, { parsers: PGLITE_INSTANT_PARSERS, ...base });
|
|
117
212
|
} catch (error) {
|
|
118
213
|
throw missing(`PGlite could not open its data directory (dataDir=${dataDir})`, error);
|
|
119
214
|
}
|
|
215
|
+
// Taken before the caller's first statement, so what is cached is `initdb`'s output and nothing
|
|
216
|
+
// of the caller's. A dump that fails leaves no cache and a working database.
|
|
217
|
+
if (file !== undefined && key !== undefined && driver.dumpDataDir !== undefined) {
|
|
218
|
+
try {
|
|
219
|
+
// Uncompressed, by measurement: gzip costs the boot that WRITES the snapshot ~0.3 s of
|
|
220
|
+
// CPU and saves nothing on the one that reads it. A fresh CI checkout always writes, so
|
|
221
|
+
// the cache must cost a cold boot nothing; the price is ~40 MB under `.x/cache`.
|
|
222
|
+
await writeSnapshot(file, key, await driver.dumpDataDir('none'));
|
|
223
|
+
} catch {
|
|
224
|
+
// The boot stands; the next one runs `initdb` again.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return driver;
|
|
120
228
|
}
|
|
121
229
|
|
|
122
230
|
/**
|
|
@@ -124,7 +232,7 @@ export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<Pgl
|
|
|
124
232
|
* both pin a connection before they `BEGIN`, and a client that cannot be pinned silently gets a
|
|
125
233
|
* shared one — which on a single-session database is every concurrent transaction at once.
|
|
126
234
|
*/
|
|
127
|
-
export interface PgliteClient extends ReservableClient {
|
|
235
|
+
export interface PgliteClient extends ReservableClient, ListeningClient {
|
|
128
236
|
/** Pay the boot up front. `x dev` calls it so the first request is not the slow one. */
|
|
129
237
|
ping(): Promise<void>;
|
|
130
238
|
close(): Promise<void>;
|
|
@@ -162,8 +270,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
162
270
|
|
|
163
271
|
/** The send itself: one statement on the session, every driver failure typed on the way out. */
|
|
164
272
|
async function send(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
|
|
273
|
+
// Above the `try`, as `sendOn` encodes above its own: a value this package refuses to send is
|
|
274
|
+
// not a driver failure.
|
|
275
|
+
refuseUnsendable(fragment.values);
|
|
276
|
+
let result: PgliteResult;
|
|
165
277
|
try {
|
|
166
|
-
|
|
278
|
+
result = await driver.query(fragment.text, fragment.values);
|
|
167
279
|
} catch (error) {
|
|
168
280
|
// `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
|
|
169
281
|
// every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
|
|
@@ -172,6 +284,10 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
172
284
|
// answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
|
|
173
285
|
throw driverError(statementExcerpt(fragment.text), error);
|
|
174
286
|
}
|
|
287
|
+
// Outside the `try`, as `sendOn` does it: a COMMIT the server answered with ROLLBACK is a
|
|
288
|
+
// refusal of its own, and `driverError` would re-wrap it as unavailability.
|
|
289
|
+
refuseRolledBackCommit(fragment.text, result);
|
|
290
|
+
return result;
|
|
175
291
|
}
|
|
176
292
|
|
|
177
293
|
/**
|
|
@@ -287,6 +403,25 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
287
403
|
issued.add(connection);
|
|
288
404
|
return connection;
|
|
289
405
|
},
|
|
406
|
+
async listen(channel, onNotify, onListening): Promise<DbSubscription> {
|
|
407
|
+
assertListenChannel(channel);
|
|
408
|
+
const driver = await connect();
|
|
409
|
+
const subscribe = driver.listen?.bind(driver);
|
|
410
|
+
if (subscribe === undefined) throw listenUnsupported('this PGlite driver');
|
|
411
|
+
// A turn of its own: the `LISTEN` is a statement on the one session, and issued beside an
|
|
412
|
+
// open transaction it would ride inside it — rolled back with it, and nothing delivered.
|
|
413
|
+
const stop = await turns.run(() => subscribe(channel, onNotify));
|
|
414
|
+
// One session and no socket to lose: established once, for the life of the client.
|
|
415
|
+
onListening?.();
|
|
416
|
+
let ended: Promise<void> | undefined;
|
|
417
|
+
return {
|
|
418
|
+
unlisten: () => {
|
|
419
|
+
// After `close()` the session is gone and so is the subscription: nothing to report.
|
|
420
|
+
ended ??= turns.run(() => stop()).catch(() => undefined);
|
|
421
|
+
return ended;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
},
|
|
290
425
|
async ping(): Promise<void> {
|
|
291
426
|
await connect();
|
|
292
427
|
},
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Single responsibility: how much of its Postgres pools this process is asking for, as three
|
|
2
|
+
// series. `Bun.SQL` publishes no occupancy — no open, idle or queued count — so this counts DEMAND
|
|
3
|
+
// at the one place every statement and every pin passes (`client.ts`) and derives the rest: a
|
|
4
|
+
// statement or a pin beyond `max` is waiting for a connection, by the pool's own rule.
|
|
5
|
+
|
|
6
|
+
import { gauge } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** One pool's ceiling and the units of work currently asking it for a connection. */
|
|
9
|
+
export interface PoolDemand {
|
|
10
|
+
/** A statement was sent, or a pin was asked for. */
|
|
11
|
+
enter(): void;
|
|
12
|
+
/** That statement settled, or that pin came back. */
|
|
13
|
+
leave(): void;
|
|
14
|
+
/** The pool closed: it stops counting toward the totals until it is asked again. */
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Tracked {
|
|
19
|
+
readonly max: number;
|
|
20
|
+
demand: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pools = new Set<Tracked>();
|
|
24
|
+
let declared = false;
|
|
25
|
+
|
|
26
|
+
const total = (pick: (pool: Tracked) => number) => (): number => {
|
|
27
|
+
let sum = 0;
|
|
28
|
+
for (const pool of pools) sum += pick(pool);
|
|
29
|
+
return sum;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** On the first tracked pool, never at import: a process that opens no pool declares no series. */
|
|
33
|
+
function declare(): void {
|
|
34
|
+
if (declared) return;
|
|
35
|
+
declared = true;
|
|
36
|
+
gauge('db_pool_max', {
|
|
37
|
+
unit: '{connection}',
|
|
38
|
+
description: 'Connections this process may open, summed over its pools',
|
|
39
|
+
observe: total((pool) => pool.max),
|
|
40
|
+
});
|
|
41
|
+
gauge('db_pool_in_use', {
|
|
42
|
+
unit: '{connection}',
|
|
43
|
+
description: 'Connections running a statement or pinned by a transaction',
|
|
44
|
+
observe: total((pool) => Math.min(pool.demand, pool.max)),
|
|
45
|
+
});
|
|
46
|
+
gauge('db_pool_waiting', {
|
|
47
|
+
unit: '{statement}',
|
|
48
|
+
description: 'Statements and pins queued for a connection because the pool is at its ceiling',
|
|
49
|
+
observe: total((pool) => Math.max(0, pool.demand - pool.max)),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One pool's counter. Registered on its first use, so a client nobody queries counts for nothing. */
|
|
54
|
+
export function trackPool(max: number): PoolDemand {
|
|
55
|
+
const pool: Tracked = { max, demand: 0 };
|
|
56
|
+
return {
|
|
57
|
+
enter(): void {
|
|
58
|
+
declare();
|
|
59
|
+
pools.add(pool);
|
|
60
|
+
pool.demand += 1;
|
|
61
|
+
},
|
|
62
|
+
leave(): void {
|
|
63
|
+
// Floored: a second `leave` for one `enter` must not hide a later statement.
|
|
64
|
+
pool.demand = Math.max(0, pool.demand - 1);
|
|
65
|
+
},
|
|
66
|
+
close(): void {
|
|
67
|
+
pools.delete(pool);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Single responsibility: a table's PRIMARY KEY as DDL — the two statements that move one, the
|
|
2
|
+
// generator's arm that decides when they are due, and the refusal for a key another table still
|
|
3
|
+
// points at. `diffTable` had no arm for it, so a changed `primaryKey` wrote no statement while the
|
|
4
|
+
// snapshot beside it recorded the new key, and drift had no comparison to notice with.
|
|
5
|
+
|
|
6
|
+
import { assert } from '@ultimat3/core';
|
|
7
|
+
import { defaultExpression } from './column-default';
|
|
8
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
9
|
+
import type { Plan } from './foreign-key-plan';
|
|
10
|
+
import { isGenerated } from './generated-column';
|
|
11
|
+
import type { SchemaDescription, TableDescription } from './introspect';
|
|
12
|
+
import { MAX_IDENTIFIER_BYTES } from './invariant-ddl';
|
|
13
|
+
import { migrationIrreversible } from './migration-errors';
|
|
14
|
+
import { identifier } from './sql';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `<table>_pkey` — what Postgres names the constraint an inline `primary key (…)` creates, which is
|
|
18
|
+
* how `createTable` has always written one. Written out by name on every `add` here, so the name a
|
|
19
|
+
* later migration drops is one a migration chose.
|
|
20
|
+
*
|
|
21
|
+
* Bounded in bytes: past 63 the server truncates the TABLE part to make room for `_pkey`, so the
|
|
22
|
+
* name it holds is no longer this string and a `drop constraint` built from it would miss.
|
|
23
|
+
*/
|
|
24
|
+
export function primaryKeyName(table: string): string {
|
|
25
|
+
const name = `${table}_pkey`;
|
|
26
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
27
|
+
assert(
|
|
28
|
+
bytes <= MAX_IDENTIFIER_BYTES,
|
|
29
|
+
`primary key constraint "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES}, so the name the database holds is not this one`,
|
|
30
|
+
`psql "$DATABASE_URL" -c "select conname from pg_constraint where contype = 'p' and conrelid = '${table}'::regclass" # then write the drop constraint / add primary key pair by hand in a new migration`,
|
|
31
|
+
);
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addPrimaryKey(table: string, columns: readonly string[]): string {
|
|
36
|
+
const key = columns.map((column) => identifier(column).text).join(', ');
|
|
37
|
+
return (
|
|
38
|
+
`alter table ${identifier(table).text} add constraint ` +
|
|
39
|
+
`${identifier(primaryKeyName(table)).text} primary key (${key});`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `if exists` for the generator, and for a reason the server supplies: dropping a COLUMN drops
|
|
45
|
+
* every constraint written over it, so a key whose column this same migration removes — in either
|
|
46
|
+
* direction — may already be gone by the time this statement runs.
|
|
47
|
+
*/
|
|
48
|
+
export function dropPrimaryKey(table: string, constraint: string, ifExists: boolean): string {
|
|
49
|
+
return (
|
|
50
|
+
`alter table ${identifier(table).text} drop constraint ` +
|
|
51
|
+
`${ifExists ? 'if exists ' : ''}${identifier(constraint).text};`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Postgres marks every key column NOT NULL and dropping the key does not undo it (measured on 17),
|
|
57
|
+
* so a column the declaration allows NULL in needs the constraint taken off by name.
|
|
58
|
+
*/
|
|
59
|
+
const dropNotNull = (table: string, column: string): string =>
|
|
60
|
+
`alter table ${identifier(table).text} alter column ${identifier(column).text} drop not null;`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* No default, or the default `null` — `.default(null)` renders that expression, and it fills an
|
|
64
|
+
* added column with exactly what no default does.
|
|
65
|
+
*/
|
|
66
|
+
const fillsNothing = (expression: string | null): boolean =>
|
|
67
|
+
expression === null || expression === 'null';
|
|
68
|
+
|
|
69
|
+
/** Two column lists, equal in ORDER — the one copy; `drift.ts` compares the live key through it. */
|
|
70
|
+
export const sameColumns = (a: readonly string[], b: readonly string[]): boolean =>
|
|
71
|
+
a.length === b.length && a.every((column, index) => column === b[index]);
|
|
72
|
+
|
|
73
|
+
/** ORDER is part of a key: `(org_id, id)` and `(id, org_id)` are two different indexes. */
|
|
74
|
+
export function keyChanged(entity: EntityDescriptionLike, live: TableDescription): boolean {
|
|
75
|
+
return !sameColumns(entity.primaryKey, live.primaryKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The recorded foreign keys written against the key being replaced. Postgres refuses to drop a
|
|
80
|
+
* constraint another one depends on (`2BP01`), and re-pointing someone else's key is not a diff
|
|
81
|
+
* this generator can derive: the referencing table's own columns would have to change with it.
|
|
82
|
+
*/
|
|
83
|
+
function inboundKeys(current: SchemaDescription, live: TableDescription): readonly string[] {
|
|
84
|
+
const key = new Set(live.primaryKey);
|
|
85
|
+
return current.tables.flatMap((table) =>
|
|
86
|
+
table.foreignKeys
|
|
87
|
+
.filter(
|
|
88
|
+
(foreign) =>
|
|
89
|
+
foreign.referencedTable === live.name &&
|
|
90
|
+
foreign.referencedColumns.length === key.size &&
|
|
91
|
+
foreign.referencedColumns.every((column) => key.has(column)),
|
|
92
|
+
)
|
|
93
|
+
.map((foreign) => foreign.name),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The first half, ahead of every column statement of the table: the old key goes. `down` is
|
|
99
|
+
* reversed at assembly, so the statement pushed here runs LAST on the way back — the old key is
|
|
100
|
+
* restored only once every column it names is back.
|
|
101
|
+
*/
|
|
102
|
+
export function dropChangedKey(
|
|
103
|
+
entity: EntityDescriptionLike,
|
|
104
|
+
live: TableDescription,
|
|
105
|
+
current: SchemaDescription,
|
|
106
|
+
plan: Plan,
|
|
107
|
+
migration: string,
|
|
108
|
+
): void {
|
|
109
|
+
if (!keyChanged(entity, live) || live.primaryKey.length === 0) return;
|
|
110
|
+
const inbound = inboundKeys(current, live);
|
|
111
|
+
if (inbound.length > 0) {
|
|
112
|
+
throw migrationIrreversible(
|
|
113
|
+
`changing the primary key of "${entity.table}" drops the constraint ${inbound.map((name) => `"${name}"`).join(', ')} ${inbound.length === 1 ? 'is' : 'are'} written against, and re-pointing another table's foreign key is not a change this generator can derive`,
|
|
114
|
+
`x db gen "${migration}" # after removing the references() to "${entity.table}" behind ${inbound.join(', ')} — drop the keys in one migration, change the primary key in the next, restore them in a third`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
plan.up.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
|
|
118
|
+
const declared = new Map(entity.columns.map((column) => [column.column, column]));
|
|
119
|
+
const kept = new Set(entity.primaryKey);
|
|
120
|
+
for (const name of live.primaryKey) {
|
|
121
|
+
// Leaving the key, still on the table, and declared nullable: the key's NOT NULL goes with it.
|
|
122
|
+
if (!kept.has(name) && declared.get(name)?.notNull === false) {
|
|
123
|
+
plan.up.push(dropNotNull(entity.table, name));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// A key column this migration DROPS comes back empty on the way down (`-- data is not
|
|
127
|
+
// restored`), and a primary key over NULLs cannot be added to a table holding a row. The
|
|
128
|
+
// statement is named as the follow-up rather than emitted as one that cannot apply — the form
|
|
129
|
+
// `diffTable` already uses for a NOT NULL add.
|
|
130
|
+
const restored = live.primaryKey.filter((name) => !declared.has(name));
|
|
131
|
+
const restore = addPrimaryKey(entity.table, live.primaryKey);
|
|
132
|
+
plan.down.push(
|
|
133
|
+
restored.length === 0
|
|
134
|
+
? restore
|
|
135
|
+
: `-- backfill ${restored.map((name) => identifier(name).text).join(', ')}, then: ${restore}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The second half, after the table's last column statement — the `drop column`s included: every
|
|
141
|
+
* column the new key names exists by now, and none it no longer names is still in the way.
|
|
142
|
+
*
|
|
143
|
+
* Refused when the key names a column this same migration ADDS with nothing to fill it (no
|
|
144
|
+
* default, or the default `null`): `add
|
|
145
|
+
* column` lands NULL in every existing row and a primary key refuses a NULL, so the generated `up`
|
|
146
|
+
* could not apply to any table holding a row. A default or a generation expression fills it.
|
|
147
|
+
*/
|
|
148
|
+
export function addChangedKey(
|
|
149
|
+
entity: EntityDescriptionLike,
|
|
150
|
+
live: TableDescription,
|
|
151
|
+
plan: Plan,
|
|
152
|
+
migration: string,
|
|
153
|
+
): void {
|
|
154
|
+
if (!keyChanged(entity, live) || entity.primaryKey.length === 0) return;
|
|
155
|
+
const recorded = new Map(live.columns.map((column) => [column.name, column]));
|
|
156
|
+
const empty = entity.columns.filter(
|
|
157
|
+
(column) =>
|
|
158
|
+
entity.primaryKey.includes(column.column) &&
|
|
159
|
+
!recorded.has(column.column) &&
|
|
160
|
+
!isGenerated(column) &&
|
|
161
|
+
fillsNothing(defaultExpression(column)),
|
|
162
|
+
);
|
|
163
|
+
if (empty.length > 0) {
|
|
164
|
+
const names = empty.map((column) => `"${column.column}"`).join(', ');
|
|
165
|
+
throw migrationIrreversible(
|
|
166
|
+
`the new primary key of "${entity.table}" names ${names}, which this same migration adds with no default that fills it: every existing row would hold NULL there, and a primary key cannot be added over a NULL`,
|
|
167
|
+
`x db gen "${migration}" # with ${names} declared but left OUT of primaryKey — apply it, backfill the column, then put it in the key and run x db gen again`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
plan.up.push(addPrimaryKey(entity.table, entity.primaryKey));
|
|
171
|
+
const old = new Set(live.primaryKey);
|
|
172
|
+
for (const name of entity.primaryKey) {
|
|
173
|
+
// Pushed BEFORE the drop so it runs AFTER it on the way down: a column that was nullable
|
|
174
|
+
// before this migration keyed it is nullable again once the key is gone.
|
|
175
|
+
if (!old.has(name) && recorded.get(name)?.nullable === true) {
|
|
176
|
+
plan.down.push(dropNotNull(entity.table, name));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
plan.down.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
|
|
180
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `@ultimat3/db/schema-dump`: the whole schema as files, and the drift checks over them. Its own
|
|
2
|
+
// entry because only three callers ever run it — `x db gen`, `x db migrate` and the gate's `drift`
|
|
3
|
+
// step — while `@ultimat3/db` is imported by every role of every app: on the barrel these ten
|
|
4
|
+
// modules were evaluated by every web, worker and scheduler pod to serve nothing.
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
CatalogColumn,
|
|
8
|
+
CatalogConstraint,
|
|
9
|
+
CatalogDescription,
|
|
10
|
+
CatalogDomain,
|
|
11
|
+
CatalogEnum,
|
|
12
|
+
CatalogExtension,
|
|
13
|
+
CatalogForeignKey,
|
|
14
|
+
CatalogFunction,
|
|
15
|
+
CatalogIndex,
|
|
16
|
+
CatalogOwnedSequence,
|
|
17
|
+
CatalogReplicaIdentity,
|
|
18
|
+
CatalogSequence,
|
|
19
|
+
CatalogTable,
|
|
20
|
+
CatalogTrigger,
|
|
21
|
+
CatalogType,
|
|
22
|
+
CatalogUnrendered,
|
|
23
|
+
CatalogView,
|
|
24
|
+
} from './catalog';
|
|
25
|
+
export { emptyCatalog } from './catalog';
|
|
26
|
+
export type { SchemaDumpDifference, SchemaDumpDifferenceKind } from './dump-drift';
|
|
27
|
+
export {
|
|
28
|
+
compareSchemaDump,
|
|
29
|
+
reloadDifferences,
|
|
30
|
+
schemaDumpDifferenceOf,
|
|
31
|
+
schemaDumpDrift,
|
|
32
|
+
} from './dump-drift';
|
|
33
|
+
export type { IntrospectCatalogOptions } from './introspect-catalog';
|
|
34
|
+
export { introspectCatalog } from './introspect-catalog';
|
|
35
|
+
export { unexpectedObjects } from './object-drift';
|
|
36
|
+
export type { SchemaDumpFile } from './schema-dump';
|
|
37
|
+
export { renderSchemaDump } from './schema-dump';
|
|
38
|
+
export type { LoadSchemaDumpOptions, SchemaLoadReport } from './schema-load';
|
|
39
|
+
export { loadSchemaDump } from './schema-load';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Single responsibility: one table as the statements that rebuild it — its `serial` sequences, the
|
|
2
|
+
// `create table`, the ownership that ties each sequence back, and its replica identity. Split from
|
|
3
|
+
// `schema-dump.ts`, which decides which file a statement goes to and never how a table is spelled.
|
|
4
|
+
|
|
5
|
+
import type { CatalogColumn, CatalogConstraint, CatalogSequence, CatalogTable } from './catalog';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every catalog name, quoted — `identifier()` (`sql.ts`) refuses whitespace and `"`, which are
|
|
9
|
+
* legal in a name a migration created, and a dump that threw on one could not describe the
|
|
10
|
+
* database it was pointed at. Doubling the quote is the whole of Postgres' identifier escape.
|
|
11
|
+
*/
|
|
12
|
+
export const quoted = (name: string): string => `"${name.replaceAll('"', '""')}"`;
|
|
13
|
+
|
|
14
|
+
/** Each option spelled, `no cycle` included, so the statement reads the same on every server. */
|
|
15
|
+
export const sequenceOptions = (sequence: CatalogSequence): string =>
|
|
16
|
+
[
|
|
17
|
+
`start with ${sequence.start}`,
|
|
18
|
+
`increment by ${sequence.increment}`,
|
|
19
|
+
`minvalue ${sequence.min}`,
|
|
20
|
+
`maxvalue ${sequence.max}`,
|
|
21
|
+
`cache ${sequence.cache}`,
|
|
22
|
+
sequence.cycle ? 'cycle' : 'no cycle',
|
|
23
|
+
].join(' ');
|
|
24
|
+
|
|
25
|
+
export const createSequence = (sequence: CatalogSequence): string =>
|
|
26
|
+
`create sequence ${quoted(sequence.name)} as ${sequence.dataType} ${sequenceOptions(sequence)};`;
|
|
27
|
+
|
|
28
|
+
/** `constraint "name" <definition>` — the definition is `pg_get_constraintdef`'s, verbatim. */
|
|
29
|
+
export const constraintClause = (constraint: CatalogConstraint): string =>
|
|
30
|
+
`constraint ${quoted(constraint.name)} ${constraint.definition}`;
|
|
31
|
+
|
|
32
|
+
/** Type, collation, then value (default, generation or identity), then nullability — one order. */
|
|
33
|
+
function columnClause(column: CatalogColumn): string {
|
|
34
|
+
const parts = [quoted(column.name), column.type];
|
|
35
|
+
if (column.collation !== null) parts.push(`collate ${quoted(column.collation)}`);
|
|
36
|
+
if (column.default !== null) parts.push(`default ${column.default}`);
|
|
37
|
+
if (column.generated !== null) {
|
|
38
|
+
const { expression, storage } = column.generated;
|
|
39
|
+
parts.push(`generated always as (${expression}) ${storage}`);
|
|
40
|
+
}
|
|
41
|
+
if (column.identity !== null) {
|
|
42
|
+
const { mode, sequence } = column.identity;
|
|
43
|
+
parts.push(
|
|
44
|
+
`generated ${mode} as identity (sequence name ${quoted(sequence.name)} ${sequenceOptions(sequence)})`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (column.notNull) parts.push('not null');
|
|
48
|
+
return parts.join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The statements of one table, in the order they must run: a `serial` column's default names its
|
|
53
|
+
* sequence, so the sequence comes first and is handed to the column only once the table exists.
|
|
54
|
+
*/
|
|
55
|
+
export function tableStatements(table: CatalogTable): readonly string[] {
|
|
56
|
+
const name = quoted(table.name);
|
|
57
|
+
const body = [...table.columns.map(columnClause), ...table.constraints.map(constraintClause)].map(
|
|
58
|
+
(line) => ` ${line}`,
|
|
59
|
+
);
|
|
60
|
+
const head = table.unlogged ? 'create unlogged table' : 'create table';
|
|
61
|
+
const tail = table.options === null ? ');' : `) with (${table.options});`;
|
|
62
|
+
const statements = [
|
|
63
|
+
...table.sequences.map(createSequence),
|
|
64
|
+
[`${head} ${name} (`, body.join(',\n'), tail].join('\n'),
|
|
65
|
+
...table.sequences.map(
|
|
66
|
+
(sequence) =>
|
|
67
|
+
`alter sequence ${quoted(sequence.name)} owned by ${name}.${quoted(sequence.column)};`,
|
|
68
|
+
),
|
|
69
|
+
];
|
|
70
|
+
// `index` waits for the indexes file: the index it names does not exist yet.
|
|
71
|
+
if (table.replicaIdentity.kind === 'full' || table.replicaIdentity.kind === 'nothing') {
|
|
72
|
+
statements.push(`alter table ${name} replica identity ${table.replicaIdentity.kind};`);
|
|
73
|
+
}
|
|
74
|
+
return statements;
|
|
75
|
+
}
|