@ultimat3/db 23.0.0 → 25.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 (46) hide show
  1. package/CLAUDE.md +83 -85
  2. package/README.md +88 -27
  3. package/package.json +3 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/catalog-fold.ts +4 -1
  7. package/src/catalog-objects.ts +29 -0
  8. package/src/catalog.ts +10 -2
  9. package/src/client.ts +2 -2
  10. package/src/column-alter.ts +9 -3
  11. package/src/commit-tag.ts +21 -0
  12. package/src/default-client.ts +4 -4
  13. package/src/dependent-view.ts +7 -5
  14. package/src/destructive.ts +1 -1
  15. package/src/drift-append-only.ts +53 -0
  16. package/src/drift-errors.ts +3 -3
  17. package/src/drift-findings.ts +211 -59
  18. package/src/drift.ts +33 -19
  19. package/src/entity-shape.ts +5 -0
  20. package/src/errors.ts +12 -8
  21. package/src/fake.ts +1 -1
  22. package/src/foreign-key.ts +0 -34
  23. package/src/generate-append-only.ts +146 -0
  24. package/src/generate.ts +20 -6
  25. package/src/index.ts +11 -9
  26. package/src/introspect-catalog.ts +19 -2
  27. package/src/introspect.ts +92 -12
  28. package/src/migrate-rollback.ts +44 -0
  29. package/src/migrate.ts +48 -158
  30. package/src/migration-ledger.ts +167 -0
  31. package/src/object-drift.ts +77 -20
  32. package/src/pglite-branch.ts +7 -7
  33. package/src/pglite.ts +16 -4
  34. package/src/pool-profile.ts +1 -1
  35. package/src/primary-key.ts +210 -0
  36. package/src/schema-dump-table.ts +4 -1
  37. package/src/sibling-turn.ts +49 -0
  38. package/src/snapshot-parse.ts +9 -3
  39. package/src/sqlstate.ts +30 -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
  44. package/src/drift-fixtures.ts +0 -23
  45. package/src/fake-pglite.ts +0 -32
  46. package/src/fake-reservable.ts +0 -50
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. */
@@ -171,7 +175,7 @@ async function restore(
171
175
  }
172
176
  }
173
177
 
174
- /** Boots one embedded Postgres. Costs seconds — `createPgliteClient` calls it exactly once. */
178
+ /** Boots one embedded Postgres. Costs seconds — `pgliteClient` calls it exactly once. */
175
179
  export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<PgliteDriver> {
176
180
  if (options.driver !== undefined) return options.driver;
177
181
  const dataDir = options.dataDir ?? PGLITE_MEMORY;
@@ -245,8 +249,8 @@ function rowsOf(result: PgliteResult): number {
245
249
  : result.rows.length;
246
250
  }
247
251
 
248
- /** Lazily boots: constructing a client opens nothing, exactly like `createPostgresClient`. */
249
- export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
252
+ /** Lazily boots: constructing a client opens nothing, exactly like `postgresClient`. */
253
+ export function pgliteClient(options: PgliteOptions = {}): PgliteClient {
250
254
  // One in-flight boot, shared. PGlite takes seconds to start, so two concurrent first queries
251
255
  // would otherwise build two instances over the same data directory and orphan one of them.
252
256
  let booting: Promise<PgliteDriver> | undefined;
@@ -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
  /**
@@ -137,7 +137,7 @@ export function assertPoolProfile(profile: PoolProfile): PoolProfile {
137
137
  assert(
138
138
  Number.isSafeInteger(value) && value >= min,
139
139
  `pool profile ${option} is ${String(value)}; it must be a whole number of ${min === 1 ? 'at least 1' : '0 or more, where 0 is the documented "no bound"'}`,
140
- `pass a whole number for ${option} in createPostgresClient({ profile }), and parse an environment value first — Number(process.env.DATABASE_${option.toUpperCase()} ?? '') is NaN when the variable is unset`,
140
+ `pass a whole number for ${option} in postgresClient({ profile }), and parse an environment value first — Number(process.env.DATABASE_${option.toUpperCase()} ?? '') is NaN when the variable is unset`,
141
141
  );
142
142
  };
143
143
  whole('max', profile.max, 1);
@@ -0,0 +1,210 @@
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, renderFixLiteral, renderFixShellArg } 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
+ * Inside shell double quotes a `$`, a backtick and a `!` still run; everything else — a quote, a
18
+ * `;`, a space — is inert once `JSON.stringify` has escaped `"` and `\\`.
19
+ */
20
+ const DOUBLE_QUOTE_LIVE = /[$`!]/;
21
+
22
+ /**
23
+ * A control character — C0, DEL, C1. `JSON.stringify` writes one as an escape (`\\n`), and inside
24
+ * shell double quotes that escape is passed on LITERALLY, so the pasted line would name a different
25
+ * migration than the one refused. The placeholder is honest; an escape is not.
26
+ */
27
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
28
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
29
+
30
+ /**
31
+ * A migration NAME as the one argument of `x db gen "…"`, for a `fix:` a reader pastes. It is a
32
+ * description (`add posts`), never an identifier, so it keeps its spaces; a name carrying shell
33
+ * syntax becomes the placeholder rather than a second command (plan 101 row S12). Lives here, the
34
+ * lowest of the three files echoing one, so `generate.ts` and `migrate.ts` share one rule.
35
+ */
36
+ export const migrationNameArg = (name: string): string =>
37
+ renderFixLiteral(
38
+ // A leading `-` reads as a flag to `x db gen` however it is quoted (sweep 1c audit, L3).
39
+ DOUBLE_QUOTE_LIVE.test(name) || CONTROL.test(name) || name.startsWith('-') ? undefined : name,
40
+ '"<a migration name>"',
41
+ );
42
+
43
+ /**
44
+ * `<table>_pkey` — what Postgres names the constraint an inline `primary key (…)` creates, which is
45
+ * how `createTable` has always written one. Written out by name on every `add` here, so the name a
46
+ * later migration drops is one a migration chose.
47
+ *
48
+ * Bounded in bytes: past 63 the server truncates the TABLE part to make room for `_pkey`, so the
49
+ * name it holds is no longer this string and a `drop constraint` built from it would miss.
50
+ */
51
+ export function primaryKeyName(table: string): string {
52
+ const name = `${table}_pkey`;
53
+ const bytes = new TextEncoder().encode(name).length;
54
+ assert(
55
+ bytes <= MAX_IDENTIFIER_BYTES,
56
+ `primary key constraint "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES}, so the name the database holds is not this one`,
57
+ // By `relname`, not `'<table>'::regclass`: a regclass literal re-parses the name as SQL, so a
58
+ // mixed-case table needs inner double quotes — which end the shell string. A name that is not
59
+ // one plain shell word is also not a safe SQL literal, so it is the placeholder (plan 101 S12).
60
+ `psql "$DATABASE_URL" -c "select con.conname from pg_constraint con join pg_class rel on rel.oid = con.conrelid where con.contype = 'p' and rel.relname = '${renderFixShellArg(table, '<table>')}'" # then write the drop constraint / add primary key pair by hand in a new migration`,
61
+ );
62
+ return name;
63
+ }
64
+
65
+ export function addPrimaryKey(table: string, columns: readonly string[]): string {
66
+ const key = columns.map((column) => identifier(column).text).join(', ');
67
+ return (
68
+ `alter table ${identifier(table).text} add constraint ` +
69
+ `${identifier(primaryKeyName(table)).text} primary key (${key});`
70
+ );
71
+ }
72
+
73
+ /**
74
+ * `if exists` for the generator, and for a reason the server supplies: dropping a COLUMN drops
75
+ * every constraint written over it, so a key whose column this same migration removes — in either
76
+ * direction — may already be gone by the time this statement runs.
77
+ */
78
+ export function dropPrimaryKey(table: string, constraint: string, ifExists: boolean): string {
79
+ return (
80
+ `alter table ${identifier(table).text} drop constraint ` +
81
+ `${ifExists ? 'if exists ' : ''}${identifier(constraint).text};`
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Postgres marks every key column NOT NULL and dropping the key does not undo it (measured on 17),
87
+ * so a column the declaration allows NULL in needs the constraint taken off by name.
88
+ */
89
+ const dropNotNull = (table: string, column: string): string =>
90
+ `alter table ${identifier(table).text} alter column ${identifier(column).text} drop not null;`;
91
+
92
+ /**
93
+ * No default, or the default `null` — `.default(null)` renders that expression, and it fills an
94
+ * added column with exactly what no default does.
95
+ */
96
+ const fillsNothing = (expression: string | null): boolean =>
97
+ expression === null || expression === 'null';
98
+
99
+ /** Two column lists, equal in ORDER — the one copy; `drift.ts` compares the live key through it. */
100
+ export const sameColumns = (a: readonly string[], b: readonly string[]): boolean =>
101
+ a.length === b.length && a.every((column, index) => column === b[index]);
102
+
103
+ /** ORDER is part of a key: `(org_id, id)` and `(id, org_id)` are two different indexes. */
104
+ export function keyChanged(entity: EntityDescriptionLike, live: TableDescription): boolean {
105
+ return !sameColumns(entity.primaryKey, live.primaryKey);
106
+ }
107
+
108
+ /**
109
+ * The recorded foreign keys written against the key being replaced. Postgres refuses to drop a
110
+ * constraint another one depends on (`2BP01`), and re-pointing someone else's key is not a diff
111
+ * this generator can derive: the referencing table's own columns would have to change with it.
112
+ */
113
+ function inboundKeys(current: SchemaDescription, live: TableDescription): readonly string[] {
114
+ const key = new Set(live.primaryKey);
115
+ return current.tables.flatMap((table) =>
116
+ table.foreignKeys
117
+ .filter(
118
+ (foreign) =>
119
+ foreign.referencedTable === live.name &&
120
+ foreign.referencedColumns.length === key.size &&
121
+ foreign.referencedColumns.every((column) => key.has(column)),
122
+ )
123
+ .map((foreign) => foreign.name),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * The first half, ahead of every column statement of the table: the old key goes. `down` is
129
+ * reversed at assembly, so the statement pushed here runs LAST on the way back — the old key is
130
+ * restored only once every column it names is back.
131
+ */
132
+ export function dropChangedKey(
133
+ entity: EntityDescriptionLike,
134
+ live: TableDescription,
135
+ current: SchemaDescription,
136
+ plan: Plan,
137
+ migration: string,
138
+ ): void {
139
+ if (!keyChanged(entity, live) || live.primaryKey.length === 0) return;
140
+ const inbound = inboundKeys(current, live);
141
+ if (inbound.length > 0) {
142
+ throw migrationIrreversible(
143
+ `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`,
144
+ `x db gen ${migrationNameArg(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`,
145
+ );
146
+ }
147
+ plan.up.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
148
+ const declared = new Map(entity.columns.map((column) => [column.column, column]));
149
+ const kept = new Set(entity.primaryKey);
150
+ for (const name of live.primaryKey) {
151
+ // Leaving the key, still on the table, and declared nullable: the key's NOT NULL goes with it.
152
+ if (!kept.has(name) && declared.get(name)?.notNull === false) {
153
+ plan.up.push(dropNotNull(entity.table, name));
154
+ }
155
+ }
156
+ // A key column this migration DROPS comes back empty on the way down (`-- data is not
157
+ // restored`), and a primary key over NULLs cannot be added to a table holding a row. The
158
+ // statement is named as the follow-up rather than emitted as one that cannot apply — the form
159
+ // `diffTable` already uses for a NOT NULL add.
160
+ const restored = live.primaryKey.filter((name) => !declared.has(name));
161
+ const restore = addPrimaryKey(entity.table, live.primaryKey);
162
+ plan.down.push(
163
+ restored.length === 0
164
+ ? restore
165
+ : `-- backfill ${restored.map((name) => identifier(name).text).join(', ')}, then: ${restore}`,
166
+ );
167
+ }
168
+
169
+ /**
170
+ * The second half, after the table's last column statement — the `drop column`s included: every
171
+ * column the new key names exists by now, and none it no longer names is still in the way.
172
+ *
173
+ * Refused when the key names a column this same migration ADDS with nothing to fill it (no
174
+ * default, or the default `null`): `add
175
+ * column` lands NULL in every existing row and a primary key refuses a NULL, so the generated `up`
176
+ * could not apply to any table holding a row. A default or a generation expression fills it.
177
+ */
178
+ export function addChangedKey(
179
+ entity: EntityDescriptionLike,
180
+ live: TableDescription,
181
+ plan: Plan,
182
+ migration: string,
183
+ ): void {
184
+ if (!keyChanged(entity, live) || entity.primaryKey.length === 0) return;
185
+ const recorded = new Map(live.columns.map((column) => [column.name, column]));
186
+ const empty = entity.columns.filter(
187
+ (column) =>
188
+ entity.primaryKey.includes(column.column) &&
189
+ !recorded.has(column.column) &&
190
+ !isGenerated(column) &&
191
+ fillsNothing(defaultExpression(column)),
192
+ );
193
+ if (empty.length > 0) {
194
+ const names = empty.map((column) => `"${column.column}"`).join(', ');
195
+ throw migrationIrreversible(
196
+ `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`,
197
+ `x db gen ${migrationNameArg(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`,
198
+ );
199
+ }
200
+ plan.up.push(addPrimaryKey(entity.table, entity.primaryKey));
201
+ const old = new Set(live.primaryKey);
202
+ for (const name of entity.primaryKey) {
203
+ // Pushed BEFORE the drop so it runs AFTER it on the way down: a column that was nullable
204
+ // before this migration keyed it is nullable again once the key is gone.
205
+ if (!old.has(name) && recorded.get(name)?.nullable === true) {
206
+ plan.down.push(dropNotNull(entity.table, name));
207
+ }
208
+ }
209
+ plan.down.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
210
+ }
@@ -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
+ }
@@ -118,14 +118,20 @@ function tableOf(value: unknown): TableDescription | undefined {
118
118
  // wrong. Any other value is garbage and takes the file with it, like every field above.
119
119
  const identity = value['replicaIdentityFull'];
120
120
  if (!(identity === undefined || bool(identity))) return undefined;
121
- const replica = identity === true ? { replicaIdentityFull: true as const } : {};
121
+ // `appendOnly` by the same rule: `true` or nothing, `false` normalised away.
122
+ const appendOnly = value['appendOnly'];
123
+ if (!(appendOnly === undefined || bool(appendOnly))) return undefined;
124
+ const flags = {
125
+ ...(identity === true ? { replicaIdentityFull: true as const } : {}),
126
+ ...(appendOnly === true ? { appendOnly: true as const } : {}),
127
+ };
122
128
  const raw = value['checks'];
123
129
  if (raw === undefined) {
124
- return { schema, name, columns, primaryKey, indexes, foreignKeys, ...replica };
130
+ return { schema, name, columns, primaryKey, indexes, foreignKeys, ...flags };
125
131
  }
126
132
  const checks = all(raw, check);
127
133
  if (checks === undefined) return undefined;
128
- return { schema, name, columns, primaryKey, indexes, foreignKeys, checks, ...replica };
134
+ return { schema, name, columns, primaryKey, indexes, foreignKeys, checks, ...flags };
129
135
  }
130
136
 
131
137
  /**
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;
@@ -6,6 +6,7 @@
6
6
  import { statementAttribution } from './attribution';
7
7
  import { encodeBoundParameters } from './bound-parameters';
8
8
  import type { BunSqlDriver } from './bun-sql';
9
+ import { refuseRolledBackCommit } from './commit-tag';
9
10
  import { driverError } from './errors';
10
11
  import { expectedQueryLoopReason } from './expected-loop';
11
12
  import { statementObserver } from './observe';
@@ -32,12 +33,18 @@ async function sendOn(
32
33
  driver: Pick<BunSqlDriver, 'unsafe'>,
33
34
  fragment: SqlFragment,
34
35
  ): Promise<unknown> {
36
+ // `encodeBoundParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
37
+ // with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
38
+ // `toString()`. One encoder here rather than one import per call site, because this is the
39
+ // only place this driver's `unsafe` is called.
40
+ //
41
+ // ABOVE the `try`: its refusals are this package's own and already coded. Inside it, a ragged
42
+ // array or an Invalid Date came back as `X_DB_UNAVAILABLE` — "set DATABASE_URL" — from a driver
43
+ // that was never called.
44
+ const values = encodeBoundParameters(fragment.values);
45
+ let result: unknown;
35
46
  try {
36
- // `encodeBoundParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
37
- // with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
38
- // `toString()`. One encoder here rather than one import per call site, because this is the
39
- // only place this driver's `unsafe` is called.
40
- return await driver.unsafe(fragment.text, encodeBoundParameters(fragment.values));
47
+ result = await driver.unsafe(fragment.text, values);
41
48
  } catch (error) {
42
49
  // `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
43
50
  // read it, so a `23505` from two clicks racing a signup told the operator the database was
@@ -45,6 +52,10 @@ async function sendOn(
45
52
  // not classify is still `X_DB_UNAVAILABLE`, byte for byte.
46
53
  throw driverError(statementExcerpt(fragment.text), error);
47
54
  }
55
+ // Outside the `try`: this refusal is already typed, and `driverError` would re-wrap it as a
56
+ // database nobody could reach.
57
+ refuseRolledBackCommit(fragment.text, result);
58
+ return result;
48
59
  }
49
60
 
50
61
  /**
@@ -0,0 +1,66 @@
1
+ // Single responsibility: the two refusals a transaction's END can raise — the server rolled it back
2
+ // while the body carried on, and the answer to COMMIT never arrived. Split from `errors.ts` at the
3
+ // file-size rule; both are thrown by `transaction.ts`, the first by the two statement funnels too.
4
+
5
+ import { renderThrowable } from '@ultimat3/core';
6
+ import { DbError } from './errors';
7
+ import { sqlState } from './sqlstate';
8
+
9
+ const ABORTED_FIX =
10
+ 'await withTransaction(() => fallible()).catch(fallback) # a nested scope is a SAVEPOINT, so ' +
11
+ 'only it rolls back — or rethrow the statement error instead of catching it';
12
+
13
+ /**
14
+ * Postgres aborts the WHOLE transaction on any statement error and answers the `COMMIT` that
15
+ * follows with the tag `ROLLBACK` and no error, so a body that caught the failure and returned was
16
+ * reported committed with nothing stored. `first` is the statement the body threw away — rendered
17
+ * into the cause, deliberately NOT chained as `sourceError`: `sqlState()` unwraps that chain, and a
18
+ * caller asking "was this a unique violation" would be answered yes by an error that means the
19
+ * whole unit of work is gone.
20
+ */
21
+ export const transactionAborted = (first?: unknown): DbError => {
22
+ const state = sqlState(first);
23
+ return new DbError({
24
+ code: 'X_DB_TRANSACTION_ABORTED',
25
+ cause:
26
+ first === undefined
27
+ ? 'COMMIT was answered with ROLLBACK: a statement failed earlier in this transaction, its error was caught, and the server had already rolled the whole unit of work back'
28
+ : `a statement failed inside the transaction and the error was caught rather than rethrown, so the server rolled the whole unit of work back: ${renderThrowable(first)}`,
29
+ fix: ABORTED_FIX,
30
+ ...(state === undefined ? {} : { meta: { sqlState: state } }),
31
+ });
32
+ };
33
+
34
+ /**
35
+ * A nested scope gave up waiting for the sibling holding the turn. Names both: the scope that
36
+ * waited is identified by its parent (it never opened, so it has no savepoint of its own), and the
37
+ * holder by the savepoint it is inside. Terminal: the usual cause is a body awaiting a sibling
38
+ * queued behind it, and the same call made again waits for the same cycle.
39
+ */
40
+ export const siblingScopeTimeout = (
41
+ parent: string,
42
+ holder: string | undefined,
43
+ waitedMs: number,
44
+ ): DbError =>
45
+ new DbError({
46
+ code: 'X_DB_SIBLING_SCOPE_TIMEOUT',
47
+ cause: `a nested scope under ${parent} waited ${waitedMs}ms for its sibling ${holder ?? 'scope'} to finish and never got a turn — sibling scopes run one after the other, so a body that awaits a sibling started after it waits for itself`,
48
+ fix: 'await withTransaction(first); await withTransaction(second) # one after the other, never a nested body awaiting a sibling — or raise the wait: withTransaction(fn, { siblingWaitMs: 120000 })',
49
+ meta: { parent, waitedMs, ...(holder === undefined ? {} : { holder }) },
50
+ });
51
+
52
+ /**
53
+ * `COMMIT` was sent and rejected with no SQLSTATE — the socket went before the answer did. The
54
+ * transaction is durable or it is not, and nothing on this side can say which, so neither list
55
+ * runs: an `onRollback` undo would revert state the database may have kept, and an `onCommit`
56
+ * effect would announce rows it may not have.
57
+ */
58
+ export const commitUnknown = (sourceError: unknown): DbError =>
59
+ new DbError({
60
+ code: 'X_DB_COMMIT_UNKNOWN',
61
+ cause: `the connection failed while COMMIT was in flight, so the transaction is either durable or rolled back and this process cannot tell which; neither onCommit effects nor onRollback undos ran. Only the data can say which — a row the transaction wrote is present if it committed and absent if it did not: ${renderThrowable(sourceError)}`,
62
+ // A session, never a `-c "<placeholder>"`: which row proves it is the caller's knowledge, and
63
+ // a placeholder inside a command is a command that does not run.
64
+ fix: 'psql "$DATABASE_URL" # a session on that database: select a row the transaction wrote, and re-run the unit of work only when it is absent',
65
+ sourceError,
66
+ });