@ultimat3/db 22.15.0 → 23.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 +39 -1
- package/README.md +81 -0
- package/package.json +5 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +113 -0
- package/src/catalog-objects.ts +200 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +166 -0
- package/src/client.ts +57 -9
- package/src/drift-findings.ts +4 -1
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +4 -0
- package/src/introspect-catalog.ts +154 -0
- package/src/listen.ts +62 -0
- package/src/object-drift.ts +105 -0
- 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 +133 -10
- package/src/pool-gauge.ts +70 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +72 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sqlstate.ts +3 -0
package/src/pglite.ts
CHANGED
|
@@ -7,8 +7,24 @@ import { statementAttribution } from './attribution';
|
|
|
7
7
|
import type { DbClient, DbConnection, ReservableClient } from './client';
|
|
8
8
|
import { DbError, driverError } from './errors';
|
|
9
9
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
|
+
import {
|
|
11
|
+
assertListenChannel,
|
|
12
|
+
type DbSubscription,
|
|
13
|
+
type ListeningClient,
|
|
14
|
+
listenUnsupported,
|
|
15
|
+
} from './listen';
|
|
10
16
|
import { statementObserver } from './observe';
|
|
11
17
|
import { PGLITE_INSTANT_PARSERS } from './pg-instant';
|
|
18
|
+
import { linkPgliteExtensions, type PgliteExtensionLoader } from './pglite-extensions';
|
|
19
|
+
import { PGLITE_PACKAGE } from './pglite-package';
|
|
20
|
+
import {
|
|
21
|
+
discardSnapshot,
|
|
22
|
+
pgliteVersion,
|
|
23
|
+
readSnapshot,
|
|
24
|
+
snapshotFile,
|
|
25
|
+
snapshotKey,
|
|
26
|
+
writeSnapshot,
|
|
27
|
+
} from './pglite-snapshot';
|
|
12
28
|
import { createTurnQueue } from './pglite-turns';
|
|
13
29
|
import type { SqlFragment } from './sql';
|
|
14
30
|
import { statementExcerpt } from './statement-excerpt';
|
|
@@ -26,6 +42,10 @@ export interface PgliteResult {
|
|
|
26
42
|
export interface PgliteDriver {
|
|
27
43
|
query(text: string, values?: readonly unknown[]): Promise<PgliteResult>;
|
|
28
44
|
exec?(text: string): Promise<unknown>;
|
|
45
|
+
/** `LISTEN` on the one session there is. Resolves to the unsubscribe. */
|
|
46
|
+
listen?(channel: string, callback: (payload: string) => void): Promise<() => Promise<void>>;
|
|
47
|
+
/** The whole data directory as one tarball — what `pglite-snapshot.ts` caches. */
|
|
48
|
+
dumpDataDir?(compression: 'none'): Promise<Blob>;
|
|
29
49
|
close(): Promise<void>;
|
|
30
50
|
}
|
|
31
51
|
|
|
@@ -33,7 +53,13 @@ export interface PgliteDriver {
|
|
|
33
53
|
export interface PgliteModule {
|
|
34
54
|
readonly PGlite: new (
|
|
35
55
|
dataDir?: string,
|
|
36
|
-
options?: {
|
|
56
|
+
options?: {
|
|
57
|
+
readonly parsers?: Readonly<Record<number, (text: string) => unknown>>;
|
|
58
|
+
/** Keyed by the bundle's own export name; each value is opaque to this package. */
|
|
59
|
+
readonly extensions?: Readonly<Record<string, unknown>>;
|
|
60
|
+
/** A `dumpDataDir()` tarball to start from instead of running `initdb`. */
|
|
61
|
+
readonly loadDataDir?: Blob;
|
|
62
|
+
},
|
|
37
63
|
) => PgliteDriver;
|
|
38
64
|
}
|
|
39
65
|
|
|
@@ -47,6 +73,24 @@ export interface PgliteOptions {
|
|
|
47
73
|
readonly driver?: PgliteDriver | undefined;
|
|
48
74
|
/** Swap the module loader. Tests use it; nothing in the framework does. */
|
|
49
75
|
readonly load?: PgliteLoader | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Extensions to make available to `create extension`, by their Postgres names (`citext`,
|
|
78
|
+
* `uuid-ossp`). PGlite links an extension only when it is handed over at boot, so a migration
|
|
79
|
+
* that creates one fails on an instance that was not told. A function, for a caller whose list
|
|
80
|
+
* is read from disk: a client is constructed synchronously and boots on its first statement.
|
|
81
|
+
* A name PGlite ships no bundle for is skipped here and refused by the server at
|
|
82
|
+
* `create extension`, in its own words (`pglite-extensions.ts`).
|
|
83
|
+
*/
|
|
84
|
+
readonly extensions?: readonly string[] | (() => Promise<readonly string[]>) | undefined;
|
|
85
|
+
/** Swap the extension bundle loader, by module specifier. Tests use it. */
|
|
86
|
+
readonly loadExtension?: PgliteExtensionLoader | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* A directory to keep the post-`initdb` snapshot in, so an in-memory boot is a restore
|
|
89
|
+
* (`pglite-snapshot.ts`). Read only for `memory://`: a directory on disk is its own snapshot.
|
|
90
|
+
*/
|
|
91
|
+
readonly snapshotDir?: string | undefined;
|
|
92
|
+
/** Swap how the installed PGlite version is read. Tests use it; `undefined` disables caching. */
|
|
93
|
+
readonly version?: (() => Promise<string | undefined>) | undefined;
|
|
50
94
|
}
|
|
51
95
|
|
|
52
96
|
export const PGLITE_FIX =
|
|
@@ -57,13 +101,9 @@ export const PGLITE_MEMORY = 'memory://';
|
|
|
57
101
|
|
|
58
102
|
const PGLITE_URL = 'pglite://';
|
|
59
103
|
|
|
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';
|
|
104
|
+
// Re-exported: `src/index.ts` and `x doctor` read it from here, and the constant itself lives in
|
|
105
|
+
// a leaf so the extension linker and the snapshot cache can share it without a cycle.
|
|
106
|
+
export { PGLITE_PACKAGE };
|
|
67
107
|
|
|
68
108
|
/**
|
|
69
109
|
* Why there is no embedded database when that specifier does not resolve. One sentence, shared:
|
|
@@ -99,6 +139,38 @@ function pgliteConstructor(loaded: unknown): PgliteModule['PGlite'] {
|
|
|
99
139
|
return exported as PgliteModule['PGlite'];
|
|
100
140
|
}
|
|
101
141
|
|
|
142
|
+
type PgliteConstructor = PgliteModule['PGlite'];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A scratch boot from the cache, or `undefined` when there is nothing sound to restore from. The
|
|
146
|
+
* restored instance is asked one statement before it is believed: a tarball can verify against
|
|
147
|
+
* its checksum and still be one this build cannot open, and that must cost a rebuild, never a
|
|
148
|
+
* failed command.
|
|
149
|
+
*/
|
|
150
|
+
async function restore(
|
|
151
|
+
PGlite: PgliteConstructor,
|
|
152
|
+
base: { readonly extensions?: Readonly<Record<string, unknown>> },
|
|
153
|
+
file: string,
|
|
154
|
+
key: string,
|
|
155
|
+
): Promise<PgliteDriver | undefined> {
|
|
156
|
+
const snapshot = await readSnapshot(file, key);
|
|
157
|
+
if (snapshot === undefined) return undefined;
|
|
158
|
+
let driver: PgliteDriver | undefined;
|
|
159
|
+
try {
|
|
160
|
+
driver = new PGlite(PGLITE_MEMORY, {
|
|
161
|
+
parsers: PGLITE_INSTANT_PARSERS,
|
|
162
|
+
...base,
|
|
163
|
+
loadDataDir: snapshot,
|
|
164
|
+
});
|
|
165
|
+
await driver.query('select 1');
|
|
166
|
+
return driver;
|
|
167
|
+
} catch {
|
|
168
|
+
await driver?.close().catch(() => undefined);
|
|
169
|
+
await discardSnapshot(file, key);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
102
174
|
/** Boots one embedded Postgres. Costs seconds — `createPgliteClient` calls it exactly once. */
|
|
103
175
|
export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<PgliteDriver> {
|
|
104
176
|
if (options.driver !== undefined) return options.driver;
|
|
@@ -110,13 +182,45 @@ export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<Pgl
|
|
|
110
182
|
throw missing(PGLITE_MISSING, error);
|
|
111
183
|
}
|
|
112
184
|
const PGlite = pgliteConstructor(loaded);
|
|
185
|
+
const names =
|
|
186
|
+
typeof options.extensions === 'function' ? await options.extensions() : options.extensions;
|
|
187
|
+
const { linked } = await linkPgliteExtensions(names ?? [], options.loadExtension);
|
|
188
|
+
// Absent, never `{}`: every boot that names no extension hands PGlite exactly what it did.
|
|
189
|
+
const base = Object.keys(linked).length === 0 ? {} : { extensions: linked };
|
|
190
|
+
const version =
|
|
191
|
+
options.snapshotDir === undefined || dataDir !== PGLITE_MEMORY
|
|
192
|
+
? undefined
|
|
193
|
+
: await (options.version ?? pgliteVersion)();
|
|
194
|
+
const key = version === undefined ? undefined : snapshotKey(version);
|
|
195
|
+
const file =
|
|
196
|
+
key === undefined || options.snapshotDir === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: snapshotFile(options.snapshotDir, key);
|
|
199
|
+
if (file !== undefined && key !== undefined) {
|
|
200
|
+
const restored = await restore(PGlite, base, file, key);
|
|
201
|
+
if (restored !== undefined) return restored;
|
|
202
|
+
}
|
|
203
|
+
let driver: PgliteDriver;
|
|
113
204
|
try {
|
|
114
205
|
// `pg-instant.ts` reads every timestamp: PGlite's own parser took year 0099 for 1999 and an
|
|
115
206
|
// offset with seconds for Invalid Date under any session zone that is not UTC.
|
|
116
|
-
|
|
207
|
+
driver = new PGlite(dataDir, { parsers: PGLITE_INSTANT_PARSERS, ...base });
|
|
117
208
|
} catch (error) {
|
|
118
209
|
throw missing(`PGlite could not open its data directory (dataDir=${dataDir})`, error);
|
|
119
210
|
}
|
|
211
|
+
// Taken before the caller's first statement, so what is cached is `initdb`'s output and nothing
|
|
212
|
+
// of the caller's. A dump that fails leaves no cache and a working database.
|
|
213
|
+
if (file !== undefined && key !== undefined && driver.dumpDataDir !== undefined) {
|
|
214
|
+
try {
|
|
215
|
+
// Uncompressed, by measurement: gzip costs the boot that WRITES the snapshot ~0.3 s of
|
|
216
|
+
// CPU and saves nothing on the one that reads it. A fresh CI checkout always writes, so
|
|
217
|
+
// the cache must cost a cold boot nothing; the price is ~40 MB under `.x/cache`.
|
|
218
|
+
await writeSnapshot(file, key, await driver.dumpDataDir('none'));
|
|
219
|
+
} catch {
|
|
220
|
+
// The boot stands; the next one runs `initdb` again.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return driver;
|
|
120
224
|
}
|
|
121
225
|
|
|
122
226
|
/**
|
|
@@ -124,7 +228,7 @@ export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<Pgl
|
|
|
124
228
|
* both pin a connection before they `BEGIN`, and a client that cannot be pinned silently gets a
|
|
125
229
|
* shared one — which on a single-session database is every concurrent transaction at once.
|
|
126
230
|
*/
|
|
127
|
-
export interface PgliteClient extends ReservableClient {
|
|
231
|
+
export interface PgliteClient extends ReservableClient, ListeningClient {
|
|
128
232
|
/** Pay the boot up front. `x dev` calls it so the first request is not the slow one. */
|
|
129
233
|
ping(): Promise<void>;
|
|
130
234
|
close(): Promise<void>;
|
|
@@ -287,6 +391,25 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
287
391
|
issued.add(connection);
|
|
288
392
|
return connection;
|
|
289
393
|
},
|
|
394
|
+
async listen(channel, onNotify, onListening): Promise<DbSubscription> {
|
|
395
|
+
assertListenChannel(channel);
|
|
396
|
+
const driver = await connect();
|
|
397
|
+
const subscribe = driver.listen?.bind(driver);
|
|
398
|
+
if (subscribe === undefined) throw listenUnsupported('this PGlite driver');
|
|
399
|
+
// A turn of its own: the `LISTEN` is a statement on the one session, and issued beside an
|
|
400
|
+
// open transaction it would ride inside it — rolled back with it, and nothing delivered.
|
|
401
|
+
const stop = await turns.run(() => subscribe(channel, onNotify));
|
|
402
|
+
// One session and no socket to lose: established once, for the life of the client.
|
|
403
|
+
onListening?.();
|
|
404
|
+
let ended: Promise<void> | undefined;
|
|
405
|
+
return {
|
|
406
|
+
unlisten: () => {
|
|
407
|
+
// After `close()` the session is gone and so is the subscription: nothing to report.
|
|
408
|
+
ended ??= turns.run(() => stop()).catch(() => undefined);
|
|
409
|
+
return ended;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
},
|
|
290
413
|
async ping(): Promise<void> {
|
|
291
414
|
await connect();
|
|
292
415
|
},
|
|
@@ -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,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,72 @@
|
|
|
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) parts.push(`generated always as (${column.generated}) stored`);
|
|
38
|
+
if (column.identity !== null) {
|
|
39
|
+
const { mode, sequence } = column.identity;
|
|
40
|
+
parts.push(
|
|
41
|
+
`generated ${mode} as identity (sequence name ${quoted(sequence.name)} ${sequenceOptions(sequence)})`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (column.notNull) parts.push('not null');
|
|
45
|
+
return parts.join(' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The statements of one table, in the order they must run: a `serial` column's default names its
|
|
50
|
+
* sequence, so the sequence comes first and is handed to the column only once the table exists.
|
|
51
|
+
*/
|
|
52
|
+
export function tableStatements(table: CatalogTable): readonly string[] {
|
|
53
|
+
const name = quoted(table.name);
|
|
54
|
+
const body = [...table.columns.map(columnClause), ...table.constraints.map(constraintClause)].map(
|
|
55
|
+
(line) => ` ${line}`,
|
|
56
|
+
);
|
|
57
|
+
const head = table.unlogged ? 'create unlogged table' : 'create table';
|
|
58
|
+
const tail = table.options === null ? ');' : `) with (${table.options});`;
|
|
59
|
+
const statements = [
|
|
60
|
+
...table.sequences.map(createSequence),
|
|
61
|
+
[`${head} ${name} (`, body.join(',\n'), tail].join('\n'),
|
|
62
|
+
...table.sequences.map(
|
|
63
|
+
(sequence) =>
|
|
64
|
+
`alter sequence ${quoted(sequence.name)} owned by ${name}.${quoted(sequence.column)};`,
|
|
65
|
+
),
|
|
66
|
+
];
|
|
67
|
+
// `index` waits for the indexes file: the index it names does not exist yet.
|
|
68
|
+
if (table.replicaIdentity.kind === 'full' || table.replicaIdentity.kind === 'nothing') {
|
|
69
|
+
statements.push(`alter table ${name} replica identity ${table.replicaIdentity.kind};`);
|
|
70
|
+
}
|
|
71
|
+
return statements;
|
|
72
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Single responsibility: a `CatalogDescription` as files — the schema dump. Pure: the same catalog
|
|
2
|
+
// is the same bytes, so the gate can compare what is committed with what the migrations produce.
|
|
3
|
+
// It decides which file a statement belongs to; `schema-dump-table.ts` spells a table, and every
|
|
4
|
+
// other object is the catalog's own `pg_get_*def` text with a terminator.
|
|
5
|
+
|
|
6
|
+
import type { CatalogDescription, CatalogType } from './catalog';
|
|
7
|
+
import { FRAMEWORK_TABLE_PREFIX } from './drift';
|
|
8
|
+
import { constraintClause, createSequence, quoted, tableStatements } from './schema-dump-table';
|
|
9
|
+
import { literal } from './sql';
|
|
10
|
+
|
|
11
|
+
export interface SchemaDumpFile {
|
|
12
|
+
/** Relative to the dump directory, POSIX: `04_tables/posts.sql`, `framework/04_tables/x_jobs.sql`. */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One directory per object kind, numbered in the order a database can be built in. */
|
|
18
|
+
export const SCHEMA_DUMP_KINDS = [
|
|
19
|
+
'01_extensions',
|
|
20
|
+
'02_types',
|
|
21
|
+
'03_sequences',
|
|
22
|
+
'04_tables',
|
|
23
|
+
'05_indexes',
|
|
24
|
+
'06_foreign_keys',
|
|
25
|
+
'07_views',
|
|
26
|
+
'08_functions',
|
|
27
|
+
'09_triggers',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type SchemaDumpKind = (typeof SCHEMA_DUMP_KINDS)[number];
|
|
31
|
+
|
|
32
|
+
/** The framework's own tables (`x_jobs`, `x_migrations`, …) live under a twin of the same layout. */
|
|
33
|
+
export const FRAMEWORK_DUMP_DIR = 'framework';
|
|
34
|
+
|
|
35
|
+
/** What the dump could not spell — comments only, so it loads as nothing. */
|
|
36
|
+
export const UNRENDERED_DUMP_FILE = 'unrendered.sql';
|
|
37
|
+
|
|
38
|
+
/** The ceiling the `filesize` step holds for source. A longer file is split, never exempted. */
|
|
39
|
+
export const SCHEMA_DUMP_MAX_LINES = 500;
|
|
40
|
+
|
|
41
|
+
export const SCHEMA_DUMP_HEADER =
|
|
42
|
+
'-- generated by `x db gen` from the migrations; never edited by hand (X_SCHEMA_DUMP_DRIFT)';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A name as a file name: anything outside `[A-Za-z0-9_-]` becomes `%XX` per UTF-8 byte. A `.` is
|
|
46
|
+
* encoded too, which is what makes the `.<n>.sql` suffix of a split file unambiguous.
|
|
47
|
+
*/
|
|
48
|
+
export function dumpFileName(name: string): string {
|
|
49
|
+
return [...new TextEncoder().encode(name)]
|
|
50
|
+
.map((byte) => {
|
|
51
|
+
const char = String.fromCharCode(byte);
|
|
52
|
+
return /[A-Za-z0-9_-]/.test(char)
|
|
53
|
+
? char
|
|
54
|
+
: `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
55
|
+
})
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `x_` rule `appTables()` already holds: the owner (a table, else the object) decides the twin. */
|
|
60
|
+
const twinOf = (owner: string): string =>
|
|
61
|
+
owner.startsWith(FRAMEWORK_TABLE_PREFIX) ? `${FRAMEWORK_DUMP_DIR}/` : '';
|
|
62
|
+
|
|
63
|
+
function typeStatement(type: CatalogType): string {
|
|
64
|
+
if (type.kind === 'enum') {
|
|
65
|
+
const labels = type.labels.map((label) => literal(label).text).join(', ');
|
|
66
|
+
return `create type ${quoted(type.name)} as enum (${labels});`;
|
|
67
|
+
}
|
|
68
|
+
const clauses = [`create domain ${quoted(type.name)} as ${type.baseType}`];
|
|
69
|
+
if (type.default !== null) clauses.push(`default ${type.default}`);
|
|
70
|
+
if (type.notNull) clauses.push('not null');
|
|
71
|
+
clauses.push(...type.checks.map(constraintClause));
|
|
72
|
+
return `${clauses.join(' ')};`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `pg_get_*def` text with exactly one terminator, whether or not the catalog wrote one. */
|
|
76
|
+
const terminated = (definition: string): string => `${definition.trim().replace(/;$/, '')};`;
|
|
77
|
+
|
|
78
|
+
const TRIGGER_STATE: ReadonlyMap<string, string> = new Map([
|
|
79
|
+
['D', 'disable trigger'],
|
|
80
|
+
['R', 'enable replica trigger'],
|
|
81
|
+
['A', 'enable always trigger'],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Statements per file path, in insertion order within a file. */
|
|
85
|
+
class Buckets {
|
|
86
|
+
private readonly files = new Map<string, string[]>();
|
|
87
|
+
|
|
88
|
+
add(kind: SchemaDumpKind, owner: string, file: string, ...statements: readonly string[]): void {
|
|
89
|
+
const path = `${twinOf(owner)}${kind}/${dumpFileName(file)}`;
|
|
90
|
+
const bucket = this.files.get(path) ?? [];
|
|
91
|
+
bucket.push(...statements);
|
|
92
|
+
this.files.set(path, bucket);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
entries(): readonly (readonly [string, readonly string[]])[] {
|
|
96
|
+
return [...this.files.entries()];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collect(catalog: CatalogDescription): Buckets {
|
|
101
|
+
const out = new Buckets();
|
|
102
|
+
const materialized = new Set(catalog.views.filter((v) => v.materialized).map((v) => v.name));
|
|
103
|
+
for (const extension of catalog.extensions) {
|
|
104
|
+
const statement = `create extension if not exists ${quoted(extension.name)};`;
|
|
105
|
+
out.add('01_extensions', extension.name, extension.name, statement);
|
|
106
|
+
}
|
|
107
|
+
for (const type of catalog.types) out.add('02_types', type.name, type.name, typeStatement(type));
|
|
108
|
+
for (const sequence of catalog.sequences) {
|
|
109
|
+
out.add('03_sequences', sequence.name, sequence.name, createSequence(sequence));
|
|
110
|
+
}
|
|
111
|
+
for (const table of catalog.tables) {
|
|
112
|
+
out.add('04_tables', table.name, table.name, ...tableStatements(table));
|
|
113
|
+
}
|
|
114
|
+
for (const view of catalog.views) {
|
|
115
|
+
const head = view.materialized ? 'create materialized view' : 'create view';
|
|
116
|
+
const options = view.options === null ? '' : ` with (${view.options})`;
|
|
117
|
+
const statement = `${head} ${quoted(view.name)}${options} as\n${terminated(view.definition)}`;
|
|
118
|
+
out.add('07_views', view.name, view.name, statement);
|
|
119
|
+
}
|
|
120
|
+
for (const index of catalog.indexes) {
|
|
121
|
+
// A materialized view's index rides in the view's own file: the view is built in a later
|
|
122
|
+
// directory than `05_indexes`, so an index filed there would name a relation not yet created.
|
|
123
|
+
const kind = materialized.has(index.table) ? '07_views' : '05_indexes';
|
|
124
|
+
out.add(kind, index.table, index.table, terminated(index.definition));
|
|
125
|
+
}
|
|
126
|
+
for (const table of catalog.tables) {
|
|
127
|
+
if (table.replicaIdentity.kind !== 'index') continue;
|
|
128
|
+
const using = `replica identity using index ${quoted(table.replicaIdentity.index)}`;
|
|
129
|
+
out.add('05_indexes', table.name, table.name, `alter table ${quoted(table.name)} ${using};`);
|
|
130
|
+
}
|
|
131
|
+
for (const key of catalog.foreignKeys) {
|
|
132
|
+
const statement = `alter table ${quoted(key.table)} add ${constraintClause(key)};`;
|
|
133
|
+
out.add('06_foreign_keys', key.table, key.table, statement);
|
|
134
|
+
}
|
|
135
|
+
for (const fn of catalog.functions) {
|
|
136
|
+
out.add('08_functions', fn.name, fn.name, terminated(fn.definition));
|
|
137
|
+
}
|
|
138
|
+
for (const trigger of catalog.triggers) {
|
|
139
|
+
const state = TRIGGER_STATE.get(trigger.enabled);
|
|
140
|
+
const on = quoted(trigger.table);
|
|
141
|
+
out.add('09_triggers', trigger.table, trigger.table, terminated(trigger.definition));
|
|
142
|
+
if (state !== undefined) {
|
|
143
|
+
const statement = `alter table ${on} ${state} ${quoted(trigger.name)};`;
|
|
144
|
+
out.add('09_triggers', trigger.table, trigger.table, statement);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One file, or `<name>.1.sql`, `<name>.2.sql`, … when it would pass the ceiling. */
|
|
151
|
+
function split(path: string, content: string): readonly SchemaDumpFile[] {
|
|
152
|
+
// The trailing newline is the file's terminator, not a line of its own.
|
|
153
|
+
const lines = content.replace(/\n$/, '').split('\n');
|
|
154
|
+
if (lines.length <= SCHEMA_DUMP_MAX_LINES) return [{ path: `${path}.sql`, content }];
|
|
155
|
+
const parts: SchemaDumpFile[] = [];
|
|
156
|
+
for (let at = 0; at < lines.length; at += SCHEMA_DUMP_MAX_LINES) {
|
|
157
|
+
const chunk = lines.slice(at, at + SCHEMA_DUMP_MAX_LINES).join('\n');
|
|
158
|
+
parts.push({ path: `${path}.${parts.length + 1}.sql`, content: `${chunk}\n` });
|
|
159
|
+
}
|
|
160
|
+
return parts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function unrenderedFiles(catalog: CatalogDescription): readonly SchemaDumpFile[] {
|
|
164
|
+
const twins = new Map<string, string[]>();
|
|
165
|
+
for (const object of catalog.unrendered) {
|
|
166
|
+
const twin = twinOf(object.table ?? object.name);
|
|
167
|
+
const on = object.table === null ? '' : ` on ${quoted(object.table)}`;
|
|
168
|
+
const lines = twins.get(twin) ?? [];
|
|
169
|
+
lines.push(`-- not rendered: ${object.kind} ${quoted(object.name)}${on}`);
|
|
170
|
+
twins.set(twin, lines);
|
|
171
|
+
}
|
|
172
|
+
return [...twins.entries()].map(([twin, lines]) => ({
|
|
173
|
+
path: `${twin}${UNRENDERED_DUMP_FILE}`,
|
|
174
|
+
content: `${SCHEMA_DUMP_HEADER}\n\n${lines.join('\n')}\n`,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The whole dump, sorted by path. Deterministic by construction: the catalog arrives sorted
|
|
180
|
+
* (`introspectCatalog`), nothing here reads a clock, a version or an owner, and every file ends
|
|
181
|
+
* in exactly one newline.
|
|
182
|
+
*/
|
|
183
|
+
export function renderSchemaDump(catalog: CatalogDescription): readonly SchemaDumpFile[] {
|
|
184
|
+
const files = collect(catalog)
|
|
185
|
+
.entries()
|
|
186
|
+
.flatMap(([path, statements]) =>
|
|
187
|
+
split(path, `${SCHEMA_DUMP_HEADER}\n\n${statements.join('\n\n')}\n`),
|
|
188
|
+
);
|
|
189
|
+
return [...files, ...unrenderedFiles(catalog)].sort((a, b) =>
|
|
190
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
|
191
|
+
);
|
|
192
|
+
}
|