@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/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?: { readonly parsers?: Readonly<Record<number, (text: string) => unknown>> },
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
- * The optional peer's specifier. Exported because `x doctor` asks whether it RESOLVES — a resolve,
62
- * never an import, since loading it boots the WASM build and takes the single-writer lock — and a
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
- return new PGlite(dataDir, { parsers: PGLITE_INSTANT_PARSERS });
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
+ }