@ultimat3/db 23.0.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/src/introspect.ts CHANGED
@@ -27,7 +27,11 @@ export interface ColumnDescription {
27
27
 
28
28
  export interface IndexDescription {
29
29
  readonly name: string;
30
- /** Physical columns in **index key order** — the order the planner sorts by, never `attnum`. */
30
+ /**
31
+ * Key columns in **index key order** — the order the planner sorts by, never `attnum`. An
32
+ * EXPRESSION key read from the catalog is its definition in parentheses (`(lower(title))`), in
33
+ * its own position: marked, so it can never be taken for a column, and never dropped.
34
+ */
31
35
  readonly columns: readonly string[];
32
36
  readonly unique: boolean;
33
37
  readonly primary: boolean;
@@ -188,17 +192,45 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
188
192
  ...(await nonAppRelations(client, schema)),
189
193
  ];
190
194
 
195
+ // The type is `format_type`, as `catalog-relations.ts` reads it: `information_schema.data_type`
196
+ // answers `numeric` for `numeric(12,2)`, `ARRAY` for `text[]` and `USER-DEFINED` for an enum, and
197
+ // `ColumnDescription.dataType` has always been documented as the first of each pair. Still FROM
198
+ // the view, which decides which columns this role may see.
191
199
  const columns = await client.query<ColumnRow>(sql`
192
- select table_name, column_name, data_type, is_nullable, column_default, ordinal_position
193
- from information_schema.columns
194
- where table_schema = ${schema}
195
- order by table_name, ordinal_position
200
+ select
201
+ c.table_name,
202
+ c.column_name,
203
+ coalesce(
204
+ (
205
+ select format_type(a.atttypid, a.atttypmod)
206
+ from pg_attribute a
207
+ join pg_class r on r.oid = a.attrelid
208
+ join pg_namespace n on n.oid = r.relnamespace
209
+ where n.nspname = c.table_schema
210
+ and r.relname = c.table_name
211
+ and a.attname = c.column_name
212
+ and a.attnum > 0
213
+ and not a.attisdropped
214
+ ),
215
+ c.data_type
216
+ ) as data_type,
217
+ c.is_nullable,
218
+ c.column_default,
219
+ c.ordinal_position
220
+ from information_schema.columns c
221
+ where c.table_schema = ${schema}
222
+ order by c.table_name, c.ordinal_position
196
223
  `);
197
224
 
198
225
  // Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
199
226
  // sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
200
227
  // other way round came back reversed — a description that reads correct and compares wrong.
201
228
  // `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
229
+ //
230
+ // A LEFT join on `pg_attribute`: an expression key has `attnum = 0` and no attribute row, so an
231
+ // inner join dropped it and `(id, lower(title))` read back as `(id)` — an index rebuilt by hand
232
+ // with an extra expression key compared equal to the declared one. It is kept in its position
233
+ // and MARKED by its parentheses, which no declared column name carries.
202
234
  const indexes = await client.query<IndexRow>(sql`
203
235
  select
204
236
  t.relname as table_name,
@@ -207,7 +239,10 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
207
239
  ix.indisprimary as is_primary,
208
240
  pg_get_expr(ix.indpred, ix.indrelid) as predicate,
209
241
  am.amname as method,
210
- array_agg(a.attname order by k.ord) as columns,
242
+ array_agg(
243
+ coalesce(a.attname::text, '(' || pg_get_indexdef(ix.indexrelid, k.ord::int, false) || ')')
244
+ order by k.ord
245
+ ) as columns,
211
246
  bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
212
247
  from pg_class t
213
248
  join pg_namespace n on n.oid = t.relnamespace
@@ -215,9 +250,11 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
215
250
  join pg_class i on i.oid = ix.indexrelid
216
251
  join pg_am am on am.oid = i.relam
217
252
  cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
218
- join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
253
+ left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
219
254
  where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
220
- group by t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname
255
+ group by
256
+ t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
257
+ ix.indexrelid, am.amname
221
258
  order by t.relname, i.relname
222
259
  `);
223
260
 
package/src/migrate.ts CHANGED
@@ -13,7 +13,7 @@ import { poolProfileFor } from './pool-profile';
13
13
  import { raw, sql } from './sql';
14
14
  import { SQLSTATE, sqlState } from './sqlstate';
15
15
  import { statementsOf } from './statement-split';
16
- import { type DbTx, withTransaction } from './transaction';
16
+ import { withTransaction } from './transaction';
17
17
 
18
18
  export const LEDGER_TABLE = 'x_migrations';
19
19
 
@@ -312,7 +312,7 @@ async function withAdvisoryLock<T>(
312
312
  * migration loop, and nesting here would replace that reason with a narrower one for no gain. An
313
313
  * empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
314
314
  */
315
- async function applyScript(tx: DbTx, script: string): Promise<void> {
315
+ async function applyScript(tx: DbClient, script: string): Promise<void> {
316
316
  for (const statement of statementsOf(script)) await tx.execute(raw(statement));
317
317
  }
318
318
 
@@ -331,7 +331,7 @@ async function applyScript(tx: DbTx, script: string): Promise<void> {
331
331
  * it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
332
332
  * `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
333
333
  */
334
- async function setLockTimeout(tx: DbTx, lockTimeoutMs: number): Promise<void> {
334
+ async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
335
335
  if (lockTimeoutMs <= 0) return;
336
336
  // `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
337
337
  await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
@@ -4,12 +4,18 @@
4
4
  // so everything else is compared here, catalog against catalog, by identity and never by text.
5
5
 
6
6
  import type { CatalogDescription } from './catalog';
7
+ import { psqlCommand } from './dependent-view';
7
8
  import { FRAMEWORK_TABLE_PREFIX } from './drift';
8
- import type { DriftDifference } from './drift-findings';
9
- import { shellInertIdentifier } from './sql';
9
+ import { byHand, type DriftDifference } from './drift-findings';
10
+ import { literal, shellInertIdentifier } from './sql';
10
11
 
11
12
  interface ObjectIdentity {
12
- /** The word `drop` takes: `trigger`, `function`, `view`, `materialized view`, `type`, `sequence`. */
13
+ /** The schema the catalog was read from — a new psql session's search_path need not hold it. */
14
+ readonly schema: string;
15
+ /**
16
+ * The word `drop` takes: `trigger`, `function`, `view`, `materialized view`, `type`, `domain`,
17
+ * `sequence`. A domain keeps its own word: it is inspected by a different psql command.
18
+ */
13
19
  readonly kind: string;
14
20
  readonly name: string;
15
21
  /** What tells two objects of one name apart: a function's arguments. */
@@ -27,13 +33,14 @@ interface ObjectIdentity {
27
33
  */
28
34
  function identities(catalog: CatalogDescription): readonly ObjectIdentity[] {
29
35
  const plain = (kind: string, name: string): ObjectIdentity => ({
36
+ schema: catalog.schema,
30
37
  kind,
31
38
  name,
32
39
  signature: '',
33
40
  table: null,
34
41
  });
35
42
  return [
36
- ...catalog.types.map((type) => plain('type', type.name)),
43
+ ...catalog.types.map((type) => plain(type.kind === 'domain' ? 'domain' : 'type', type.name)),
37
44
  ...catalog.sequences.map((sequence) => plain('sequence', sequence.name)),
38
45
  ...catalog.views.map((view) =>
39
46
  plain(view.materialized ? 'materialized view' : 'view', view.name),
@@ -53,38 +60,88 @@ const SIGNATURE_ACTIVE = /[`$\\\u0000-\u001f\u007f]/;
53
60
  const keyOf = (object: ObjectIdentity): string =>
54
61
  [object.kind, object.table ?? '', object.name, object.signature].join('\u0000');
55
62
 
63
+ /** The psql command that PRINTS a kind's definition — what a migration's create statement is copied from. */
64
+ const SHOW = Object.freeze<Record<string, string>>({
65
+ view: '\\d+',
66
+ 'materialized view': '\\d+',
67
+ type: '\\dT+',
68
+ // `\dT+` LISTS a domain and shows none of it; its base type and CHECKs are `\dD+`'s (measured, 17).
69
+ domain: '\\dD+',
70
+ sequence: '\\d',
71
+ // A trigger has no command of its own: `\d` on its table lists it, definition included.
72
+ trigger: '\\d',
73
+ });
74
+
75
+ /**
76
+ * A function's definition, asked for by the three facts the catalog gave: its schema, its name and
77
+ * its identity arguments. Not `\\sf name(args)`: that parses a TYPE list, and the identity arguments
78
+ * carry the parameter NAMES (`a text`), which it answers with a syntax error — measured on 17. By
79
+ * NAMESPACE and never `pg_function_is_visible`: visibility is the session's search_path, which can
80
+ * hide this function or answer a same-named one from another schema. All three are data, so all
81
+ * three go through `literal()`.
82
+ */
83
+ const showFunction = (object: ObjectIdentity): string =>
84
+ 'select pg_get_functiondef(p.oid) from pg_proc p join pg_namespace n on n.oid = ' +
85
+ `p.pronamespace where n.nspname = ${literal(object.schema).text} and ` +
86
+ `p.proname = ${literal(object.name).text} and ` +
87
+ `pg_get_function_identity_arguments(p.oid) = ${literal(object.signature).text}`;
88
+
56
89
  /**
57
- * The `drop` comes FIRST in the fix, and that order is the instruction: a migration that creates
58
- * an object the database already holds fails on `already exists`, so the hand-made copy has to go
59
- * before the migration that owns it can apply.
90
+ * The fix is ONE command a shell runs, and it is the harmless one: it prints the object's
91
+ * definition. The repair is the comment, in the order it has to happen — copy the definition into
92
+ * a migration, drop the hand-made copy (a migration creating an object the database already holds
93
+ * fails on `already exists`), then migrate. It used to lead with `run drop …; inside psql`: prose
94
+ * no shell runs, whose first step destroyed the definition the second step needed.
60
95
  */
61
96
  function unexpectedObject(object: ObjectIdentity): DriftDifference {
97
+ const schema = shellInertIdentifier(object.schema);
62
98
  const name = shellInertIdentifier(object.name);
63
99
  const table = object.table === null ? null : shellInertIdentifier(object.table);
64
100
  const where = object.table === null ? '' : ` on table "${object.table}"`;
65
101
  const signature = object.signature === '' ? '' : `(${object.signature})`;
66
- // A function is dropped by its argument list, empty included: `drop function "add";` is
102
+ // A function is named by its argument list, empty included: `drop function "add";` is
67
103
  // `42725 function name is not unique` while an overload lives beside it. The list is catalog
68
104
  // text (`pg_get_function_identity_arguments`), already quoted for SQL, so it is screened for
69
105
  // what a shell or a pasted line would read and never escaped.
70
106
  const args = object.kind === 'function' ? `(${object.signature})` : '';
71
107
  const spellable =
108
+ schema !== null &&
72
109
  name !== null &&
73
110
  (object.table === null || table !== null) &&
74
111
  !SIGNATURE_ACTIVE.test(object.signature);
75
- const drop = `drop ${object.kind} ${name}${args}${table === null ? '' : ` on ${table}`};`;
112
+ const cause = `${object.kind} "${object.name}"${signature}${where} exists in this database and no migration creates it`;
113
+ const kind = 'unexpected-object';
114
+ const base = { kind, table: object.table ?? object.name, column: null, cause } as const;
115
+ if (!spellable) {
116
+ return {
117
+ ...base,
118
+ fix: byHand(
119
+ 'copy the definition of the object this difference names into a migration as a create ' +
120
+ 'statement, then drop it',
121
+ 'its schema, name or arguments carry a backtick, a dollar sign, a quote, a backslash or ' +
122
+ 'whitespace, so no statement here can spell it',
123
+ ),
124
+ };
125
+ }
126
+ // Every target is qualified by the catalog's schema: unqualified, `\d "posts"` in a session
127
+ // whose search_path lacks that schema answers "Did not find any relation" — and exits 0.
128
+ const drop =
129
+ table === null
130
+ ? `drop ${object.kind} ${schema}.${name}${args};`
131
+ : `drop ${object.kind} ${name} on ${schema}.${table};`;
132
+ // The comment repeats the statement only when it holds no `'`: a shell that does not read `#`
133
+ // as a comment would open a quote on one. The command is safe either way (`psqlCommand`).
134
+ const spoken = drop.includes("'") ? `drop ${object.kind} on it` : drop;
135
+ const show = psqlCommand(
136
+ object.kind === 'function'
137
+ ? showFunction(object)
138
+ : `${SHOW[object.kind] ?? '\\d'} ${schema}.${table ?? name}`,
139
+ );
76
140
  return {
77
- kind: 'unexpected-object',
78
- table: object.table ?? object.name,
79
- column: null,
80
- cause: `${object.kind} "${object.name}"${signature}${where} exists in this database and no migration creates it`,
81
- fix: spellable
82
- ? `run ${drop} inside psql "$DATABASE_URL", then write its create statement into a ` +
83
- 'migration and run x db migrate — or leave it dropped if nothing owns it'
84
- : 'drop it by hand, then write its create statement into a migration and run x db migrate ' +
85
- '— its name or arguments carry a backtick, a dollar sign, a quote, a backslash or ' +
86
- 'whitespace, so ' +
87
- 'no statement here can spell it',
141
+ ...base,
142
+ fix:
143
+ `${show} # no migration creates it: copy its definition into a migration as a create ` +
144
+ `statement, run ${spoken} here, then x db migrate — or only drop it if nothing owns it`,
88
145
  };
89
146
  }
90
147
 
@@ -5,10 +5,10 @@
5
5
 
6
6
  import { cp, mkdir, rm, stat } from 'node:fs/promises';
7
7
  import { basename, dirname, join } from 'node:path';
8
- import { systemClock } from '@ultimat3/core';
8
+ import { NotImplementedError, systemClock } from '@ultimat3/core';
9
9
  import type { BranchInfo } from './branch';
10
10
  import { assertBranchName } from './branch';
11
- import { branchExists, dbNotImplemented, dbUnavailable } from './errors';
11
+ import { branchExists, dbUnavailable } from './errors';
12
12
  import { PGLITE_MEMORY, pgliteDataDir } from './pglite';
13
13
 
14
14
  export interface PgliteBranchOptions {
@@ -56,10 +56,10 @@ export async function branchPglite(
56
56
  assertBranchName(branch);
57
57
  const from = pgliteDataDir(options.from);
58
58
  if (from === PGLITE_MEMORY) {
59
- throw dbNotImplemented(
60
- 'branching an in-memory PGlite',
61
- 'x dev # a branch copies .x/pgdata, so the database has to be on disk first',
62
- );
59
+ throw new NotImplementedError({
60
+ cause: 'branching an in-memory PGlite is not implemented by this driver',
61
+ fix: 'x dev # a branch copies .x/pgdata, so the database has to be on disk first',
62
+ });
63
63
  }
64
64
  if (!(await isDirectory(from))) {
65
65
  throw dbUnavailable(`there is no PGlite data directory at ${from}, so nothing to branch`);
package/src/pglite.ts CHANGED
@@ -4,7 +4,9 @@
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';
10
12
  import {
@@ -36,6 +38,8 @@ export interface PgliteResult {
36
38
  readonly rows: readonly unknown[];
37
39
  /** Postgres' command-tag count — the only truthful answer for INSERT/UPDATE/DELETE. */
38
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;
39
43
  }
40
44
 
41
45
  /** The slice of PGlite we need. Declared structurally — this package has no dependencies. */
@@ -266,8 +270,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
266
270
 
267
271
  /** The send itself: one statement on the session, every driver failure typed on the way out. */
268
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;
269
277
  try {
270
- return await driver.query(fragment.text, fragment.values);
278
+ result = await driver.query(fragment.text, fragment.values);
271
279
  } catch (error) {
272
280
  // `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
273
281
  // every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
@@ -276,6 +284,10 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
276
284
  // answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
277
285
  throw driverError(statementExcerpt(fragment.text), error);
278
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;
279
291
  }
280
292
 
281
293
  /**
@@ -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
+ }
@@ -34,7 +34,10 @@ function columnClause(column: CatalogColumn): string {
34
34
  const parts = [quoted(column.name), column.type];
35
35
  if (column.collation !== null) parts.push(`collate ${quoted(column.collation)}`);
36
36
  if (column.default !== null) parts.push(`default ${column.default}`);
37
- if (column.generated !== null) parts.push(`generated always as (${column.generated}) stored`);
37
+ if (column.generated !== null) {
38
+ const { expression, storage } = column.generated;
39
+ parts.push(`generated always as (${expression}) ${storage}`);
40
+ }
38
41
  if (column.identity !== null) {
39
42
  const { mode, sequence } = column.identity;
40
43
  parts.push(
@@ -0,0 +1,49 @@
1
+ // Single responsibility: a nested scope's wait for its turn among its siblings, under a deadline.
2
+ // Sibling scopes run one after the other (savepoints are a stack), so a body that awaits a sibling
3
+ // queued BEHIND it is a cycle nothing can break from inside — and an unbounded wait made it a
4
+ // silent, permanent hang. Same shape as `pool-reserve.ts`: a late turn is given back, never dropped.
5
+
6
+ import type { DbError } from './errors';
7
+ import type { Turn, TurnQueue } from './pglite-turns';
8
+
9
+ /**
10
+ * The default wait, in milliseconds. Above the serving roles' `statement_timeout` (10–15 s,
11
+ * `pool-profile.ts`), so a sibling that is merely inside one slow statement finishes or fails
12
+ * first; `{ siblingWaitMs }` moves it and `0` removes it.
13
+ */
14
+ export const SIBLING_SCOPE_WAIT_MS = 30_000;
15
+
16
+ /**
17
+ * `queue.take()` under a deadline. The place in the queue is claimed the moment `take()` is called
18
+ * and cannot be withdrawn, so a wait that gives up must still hand the turn straight on when it
19
+ * arrives — otherwise every later sibling waits behind a scope that no longer exists.
20
+ */
21
+ export async function siblingTurn(
22
+ queue: TurnQueue,
23
+ waitMs: number,
24
+ refusal: () => DbError,
25
+ ): Promise<Turn> {
26
+ const pending = queue.take();
27
+ if (waitMs === 0) return pending;
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+ let expired = false;
30
+ try {
31
+ return await Promise.race([
32
+ pending,
33
+ new Promise<never>((_resolve, reject) => {
34
+ timer = setTimeout(() => {
35
+ expired = true;
36
+ // Built at expiry, so it names the scope holding the turn NOW.
37
+ reject(refusal());
38
+ }, waitMs);
39
+ // The deadline must not be what keeps a finished process alive.
40
+ timer.unref?.();
41
+ }),
42
+ ]);
43
+ } finally {
44
+ if (timer !== undefined) clearTimeout(timer);
45
+ void pending.then((late) => {
46
+ if (expired) late.release();
47
+ });
48
+ }
49
+ }
package/src/sqlstate.ts CHANGED
@@ -31,12 +31,6 @@ export const SQLSTATE = Object.freeze({
31
31
  outOfMemory: '53200',
32
32
  } as const);
33
33
 
34
- /** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
35
- const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
36
-
37
- /** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
38
- const MAX_WRAPS = 4;
39
-
40
34
  /** A field off a value that may fight being read — `stringField`'s shape, for a non-string. */
41
35
  function unknownField(value: unknown, key: string): unknown {
42
36
  if (typeof value !== 'object' || value === null) return undefined;
@@ -47,6 +41,32 @@ function unknownField(value: unknown, key: string): unknown {
47
41
  }
48
42
  }
49
43
 
44
+ /** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
45
+ const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
46
+
47
+ /**
48
+ * Whether five characters of that shape are a SQLSTATE, decided by where the object CAME FROM —
49
+ * the shape alone cannot say. `EPIPE` and `E2BIG` are errno names of exactly that shape, and
50
+ * `raise exception … using errcode = 'ABCDE'` is a legal state with no digit in it, so a rule
51
+ * about letters and digits is wrong in both directions.
52
+ *
53
+ * Measured on Bun.SQL against Postgres 17 and on PGlite: a server ErrorResponse carries
54
+ * `severity` on both drivers, and nothing the socket layer throws does. A syscall error carries
55
+ * `syscall` and a NUMERIC `errno`. An object marked as neither — a fake, a wrapper, a driver this
56
+ * package has not measured — keeps the old reading only for a state that carries a digit, which
57
+ * is every state Postgres itself defines and no bare errno name this package has been handed.
58
+ */
59
+ function isState(holder: unknown, candidate: string): boolean {
60
+ if (!SQLSTATE_SHAPE.test(candidate)) return false;
61
+ if (stringField(holder, 'severity') !== undefined) return true;
62
+ if (stringField(holder, 'syscall') !== undefined) return false;
63
+ if (typeof unknownField(holder, 'errno') === 'number') return false;
64
+ return /[0-9]/.test(candidate);
65
+ }
66
+
67
+ /** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
68
+ const MAX_WRAPS = 4;
69
+
50
70
  /**
51
71
  * The SQLSTATE a driver error carries, unwrapping `DbError.sourceError` on the way, or `undefined`
52
72
  * when the failure never reached the server — a refused socket, a closed pool, a DNS miss.
@@ -57,17 +77,17 @@ function unknownField(value: unknown, key: string): unknown {
57
77
  * has no `errno` at all. Reading `code` alone is correct on the embedded driver and wrong on every
58
78
  * production one, which is exactly the split `isLedgerMissing` was living on.
59
79
  *
60
- * The shape test is what keeps the two apart: `ERR_POSTGRES_SERVER_ERROR` and `X_DB_UNAVAILABLE`
61
- * are not five characters of `[0-9A-Z]`, and no SQLSTATE contains an underscore.
80
+ * The shape test keeps `ERR_POSTGRES_SERVER_ERROR` and `X_DB_UNAVAILABLE` out — neither is five
81
+ * characters of `[0-9A-Z]` — and `isState` keeps an errno NAME out, which the shape cannot.
62
82
  */
63
83
  export function sqlState(error: unknown): string | undefined {
64
84
  let value = error;
65
85
  for (let depth = 0; depth < MAX_WRAPS; depth += 1) {
66
86
  if (value === undefined || value === null) return undefined;
67
87
  const errno = stringField(value, 'errno');
68
- if (errno !== undefined && SQLSTATE_SHAPE.test(errno)) return errno;
88
+ if (errno !== undefined && isState(value, errno)) return errno;
69
89
  const code = stringField(value, 'code');
70
- if (code !== undefined && SQLSTATE_SHAPE.test(code)) return code;
90
+ if (code !== undefined && isState(value, code)) return code;
71
91
  value = unknownField(value, 'sourceError');
72
92
  }
73
93
  return undefined;