@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.
Files changed (43) hide show
  1. package/CLAUDE.md +91 -56
  2. package/README.md +121 -6
  3. package/package.json +5 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/bun-sql.ts +12 -0
  7. package/src/catalog-fold.ts +116 -0
  8. package/src/catalog-objects.ts +229 -0
  9. package/src/catalog-relations.ts +184 -0
  10. package/src/catalog.ts +174 -0
  11. package/src/client.ts +57 -9
  12. package/src/commit-tag.ts +21 -0
  13. package/src/dependent-view.ts +6 -4
  14. package/src/drift-errors.ts +3 -3
  15. package/src/drift-findings.ts +213 -60
  16. package/src/drift.ts +19 -13
  17. package/src/dump-drift.ts +142 -0
  18. package/src/errors.ts +8 -7
  19. package/src/foreign-key.ts +0 -34
  20. package/src/generate.ts +5 -0
  21. package/src/index.ts +9 -3
  22. package/src/introspect-catalog.ts +171 -0
  23. package/src/introspect.ts +45 -8
  24. package/src/listen.ts +62 -0
  25. package/src/migrate.ts +3 -3
  26. package/src/object-drift.ts +162 -0
  27. package/src/pglite-branch.ts +6 -6
  28. package/src/pglite-extensions.ts +112 -0
  29. package/src/pglite-package.ts +11 -0
  30. package/src/pglite-snapshot.ts +121 -0
  31. package/src/pglite.ts +146 -11
  32. package/src/pool-gauge.ts +70 -0
  33. package/src/primary-key.ts +180 -0
  34. package/src/schema-dump-entry.ts +39 -0
  35. package/src/schema-dump-table.ts +75 -0
  36. package/src/schema-dump.ts +192 -0
  37. package/src/schema-load.ts +119 -0
  38. package/src/sibling-turn.ts +49 -0
  39. package/src/sqlstate.ts +33 -10
  40. package/src/statement-funnel.ts +16 -5
  41. package/src/transaction-errors.ts +66 -0
  42. package/src/transaction-options.ts +122 -0
  43. 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?: { readonly parsers?: Readonly<Record<number, (text: string) => unknown>> },
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
- * 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';
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
- return new PGlite(dataDir, { parsers: PGLITE_INSTANT_PARSERS });
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
- return await driver.query(fragment.text, fragment.values);
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
+ }