@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
@@ -0,0 +1,192 @@
1
+ // Single responsibility: a `CatalogDescription` as files — the schema dump. Pure: the same catalog
2
+ // is the same bytes, so the gate can compare what is committed with what the migrations produce.
3
+ // It decides which file a statement belongs to; `schema-dump-table.ts` spells a table, and every
4
+ // other object is the catalog's own `pg_get_*def` text with a terminator.
5
+
6
+ import type { CatalogDescription, CatalogType } from './catalog';
7
+ import { FRAMEWORK_TABLE_PREFIX } from './drift';
8
+ import { constraintClause, createSequence, quoted, tableStatements } from './schema-dump-table';
9
+ import { literal } from './sql';
10
+
11
+ export interface SchemaDumpFile {
12
+ /** Relative to the dump directory, POSIX: `04_tables/posts.sql`, `framework/04_tables/x_jobs.sql`. */
13
+ readonly path: string;
14
+ readonly content: string;
15
+ }
16
+
17
+ /** One directory per object kind, numbered in the order a database can be built in. */
18
+ export const SCHEMA_DUMP_KINDS = [
19
+ '01_extensions',
20
+ '02_types',
21
+ '03_sequences',
22
+ '04_tables',
23
+ '05_indexes',
24
+ '06_foreign_keys',
25
+ '07_views',
26
+ '08_functions',
27
+ '09_triggers',
28
+ ] as const;
29
+
30
+ export type SchemaDumpKind = (typeof SCHEMA_DUMP_KINDS)[number];
31
+
32
+ /** The framework's own tables (`x_jobs`, `x_migrations`, …) live under a twin of the same layout. */
33
+ export const FRAMEWORK_DUMP_DIR = 'framework';
34
+
35
+ /** What the dump could not spell — comments only, so it loads as nothing. */
36
+ export const UNRENDERED_DUMP_FILE = 'unrendered.sql';
37
+
38
+ /** The ceiling the `filesize` step holds for source. A longer file is split, never exempted. */
39
+ export const SCHEMA_DUMP_MAX_LINES = 500;
40
+
41
+ export const SCHEMA_DUMP_HEADER =
42
+ '-- generated by `x db gen` from the migrations; never edited by hand (X_SCHEMA_DUMP_DRIFT)';
43
+
44
+ /**
45
+ * A name as a file name: anything outside `[A-Za-z0-9_-]` becomes `%XX` per UTF-8 byte. A `.` is
46
+ * encoded too, which is what makes the `.<n>.sql` suffix of a split file unambiguous.
47
+ */
48
+ export function dumpFileName(name: string): string {
49
+ return [...new TextEncoder().encode(name)]
50
+ .map((byte) => {
51
+ const char = String.fromCharCode(byte);
52
+ return /[A-Za-z0-9_-]/.test(char)
53
+ ? char
54
+ : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
55
+ })
56
+ .join('');
57
+ }
58
+
59
+ /** The `x_` rule `appTables()` already holds: the owner (a table, else the object) decides the twin. */
60
+ const twinOf = (owner: string): string =>
61
+ owner.startsWith(FRAMEWORK_TABLE_PREFIX) ? `${FRAMEWORK_DUMP_DIR}/` : '';
62
+
63
+ function typeStatement(type: CatalogType): string {
64
+ if (type.kind === 'enum') {
65
+ const labels = type.labels.map((label) => literal(label).text).join(', ');
66
+ return `create type ${quoted(type.name)} as enum (${labels});`;
67
+ }
68
+ const clauses = [`create domain ${quoted(type.name)} as ${type.baseType}`];
69
+ if (type.default !== null) clauses.push(`default ${type.default}`);
70
+ if (type.notNull) clauses.push('not null');
71
+ clauses.push(...type.checks.map(constraintClause));
72
+ return `${clauses.join(' ')};`;
73
+ }
74
+
75
+ /** `pg_get_*def` text with exactly one terminator, whether or not the catalog wrote one. */
76
+ const terminated = (definition: string): string => `${definition.trim().replace(/;$/, '')};`;
77
+
78
+ const TRIGGER_STATE: ReadonlyMap<string, string> = new Map([
79
+ ['D', 'disable trigger'],
80
+ ['R', 'enable replica trigger'],
81
+ ['A', 'enable always trigger'],
82
+ ]);
83
+
84
+ /** Statements per file path, in insertion order within a file. */
85
+ class Buckets {
86
+ private readonly files = new Map<string, string[]>();
87
+
88
+ add(kind: SchemaDumpKind, owner: string, file: string, ...statements: readonly string[]): void {
89
+ const path = `${twinOf(owner)}${kind}/${dumpFileName(file)}`;
90
+ const bucket = this.files.get(path) ?? [];
91
+ bucket.push(...statements);
92
+ this.files.set(path, bucket);
93
+ }
94
+
95
+ entries(): readonly (readonly [string, readonly string[]])[] {
96
+ return [...this.files.entries()];
97
+ }
98
+ }
99
+
100
+ function collect(catalog: CatalogDescription): Buckets {
101
+ const out = new Buckets();
102
+ const materialized = new Set(catalog.views.filter((v) => v.materialized).map((v) => v.name));
103
+ for (const extension of catalog.extensions) {
104
+ const statement = `create extension if not exists ${quoted(extension.name)};`;
105
+ out.add('01_extensions', extension.name, extension.name, statement);
106
+ }
107
+ for (const type of catalog.types) out.add('02_types', type.name, type.name, typeStatement(type));
108
+ for (const sequence of catalog.sequences) {
109
+ out.add('03_sequences', sequence.name, sequence.name, createSequence(sequence));
110
+ }
111
+ for (const table of catalog.tables) {
112
+ out.add('04_tables', table.name, table.name, ...tableStatements(table));
113
+ }
114
+ for (const view of catalog.views) {
115
+ const head = view.materialized ? 'create materialized view' : 'create view';
116
+ const options = view.options === null ? '' : ` with (${view.options})`;
117
+ const statement = `${head} ${quoted(view.name)}${options} as\n${terminated(view.definition)}`;
118
+ out.add('07_views', view.name, view.name, statement);
119
+ }
120
+ for (const index of catalog.indexes) {
121
+ // A materialized view's index rides in the view's own file: the view is built in a later
122
+ // directory than `05_indexes`, so an index filed there would name a relation not yet created.
123
+ const kind = materialized.has(index.table) ? '07_views' : '05_indexes';
124
+ out.add(kind, index.table, index.table, terminated(index.definition));
125
+ }
126
+ for (const table of catalog.tables) {
127
+ if (table.replicaIdentity.kind !== 'index') continue;
128
+ const using = `replica identity using index ${quoted(table.replicaIdentity.index)}`;
129
+ out.add('05_indexes', table.name, table.name, `alter table ${quoted(table.name)} ${using};`);
130
+ }
131
+ for (const key of catalog.foreignKeys) {
132
+ const statement = `alter table ${quoted(key.table)} add ${constraintClause(key)};`;
133
+ out.add('06_foreign_keys', key.table, key.table, statement);
134
+ }
135
+ for (const fn of catalog.functions) {
136
+ out.add('08_functions', fn.name, fn.name, terminated(fn.definition));
137
+ }
138
+ for (const trigger of catalog.triggers) {
139
+ const state = TRIGGER_STATE.get(trigger.enabled);
140
+ const on = quoted(trigger.table);
141
+ out.add('09_triggers', trigger.table, trigger.table, terminated(trigger.definition));
142
+ if (state !== undefined) {
143
+ const statement = `alter table ${on} ${state} ${quoted(trigger.name)};`;
144
+ out.add('09_triggers', trigger.table, trigger.table, statement);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** One file, or `<name>.1.sql`, `<name>.2.sql`, … when it would pass the ceiling. */
151
+ function split(path: string, content: string): readonly SchemaDumpFile[] {
152
+ // The trailing newline is the file's terminator, not a line of its own.
153
+ const lines = content.replace(/\n$/, '').split('\n');
154
+ if (lines.length <= SCHEMA_DUMP_MAX_LINES) return [{ path: `${path}.sql`, content }];
155
+ const parts: SchemaDumpFile[] = [];
156
+ for (let at = 0; at < lines.length; at += SCHEMA_DUMP_MAX_LINES) {
157
+ const chunk = lines.slice(at, at + SCHEMA_DUMP_MAX_LINES).join('\n');
158
+ parts.push({ path: `${path}.${parts.length + 1}.sql`, content: `${chunk}\n` });
159
+ }
160
+ return parts;
161
+ }
162
+
163
+ function unrenderedFiles(catalog: CatalogDescription): readonly SchemaDumpFile[] {
164
+ const twins = new Map<string, string[]>();
165
+ for (const object of catalog.unrendered) {
166
+ const twin = twinOf(object.table ?? object.name);
167
+ const on = object.table === null ? '' : ` on ${quoted(object.table)}`;
168
+ const lines = twins.get(twin) ?? [];
169
+ lines.push(`-- not rendered: ${object.kind} ${quoted(object.name)}${on}`);
170
+ twins.set(twin, lines);
171
+ }
172
+ return [...twins.entries()].map(([twin, lines]) => ({
173
+ path: `${twin}${UNRENDERED_DUMP_FILE}`,
174
+ content: `${SCHEMA_DUMP_HEADER}\n\n${lines.join('\n')}\n`,
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * The whole dump, sorted by path. Deterministic by construction: the catalog arrives sorted
180
+ * (`introspectCatalog`), nothing here reads a clock, a version or an owner, and every file ends
181
+ * in exactly one newline.
182
+ */
183
+ export function renderSchemaDump(catalog: CatalogDescription): readonly SchemaDumpFile[] {
184
+ const files = collect(catalog)
185
+ .entries()
186
+ .flatMap(([path, statements]) =>
187
+ split(path, `${SCHEMA_DUMP_HEADER}\n\n${statements.join('\n\n')}\n`),
188
+ );
189
+ return [...files, ...unrenderedFiles(catalog)].sort((a, b) =>
190
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
191
+ );
192
+ }
@@ -0,0 +1,119 @@
1
+ // Single responsibility: build a schema from the dump `schema-dump.ts` wrote — every file's
2
+ // statements, in kind order, inside one transaction. The inverse of the dump and the half of
3
+ // "load equals replay" that proves the files are a cache of the migrations rather than a second
4
+ // source: load, introspect, render again, and the bytes must be the ones that were loaded.
5
+
6
+ import { renderThrowable } from '@ultimat3/core';
7
+ import type { DbClient } from './client';
8
+ import { baseClient } from './client';
9
+ import { schemaDumpDrift, unloadableDump } from './dump-drift';
10
+ import { DbError } from './errors';
11
+ import { FRAMEWORK_DUMP_DIR, type SchemaDumpFile } from './schema-dump';
12
+ import { raw } from './sql';
13
+ import { SQLSTATE, sqlState } from './sqlstate';
14
+ import { statementsOf } from './statement-split';
15
+ import { withTransaction } from './transaction';
16
+
17
+ export interface LoadSchemaDumpOptions {
18
+ readonly files: readonly SchemaDumpFile[];
19
+ readonly client?: DbClient | undefined;
20
+ }
21
+
22
+ export interface SchemaLoadReport {
23
+ readonly files: number;
24
+ readonly statements: number;
25
+ }
26
+
27
+ /** One loadable unit: a whole file, or the numbered parts of a split one joined back together. */
28
+ interface Unit {
29
+ readonly path: string;
30
+ readonly kind: string;
31
+ readonly framework: boolean;
32
+ readonly script: string;
33
+ }
34
+
35
+ const SPLIT_PART = /^(.*)\.(\d+)\.sql$/;
36
+
37
+ /**
38
+ * Files regrouped into units and put in load order: by kind directory, the framework twin first
39
+ * within a kind (an app's foreign key may point at `x_users`; nothing of the framework's points at
40
+ * the app), then by name. A split file's parts are joined in NUMERIC order — `.10.sql` after
41
+ * `.9.sql` — because a statement may straddle the cut.
42
+ */
43
+ export function loadOrder(files: readonly SchemaDumpFile[]): readonly Unit[] {
44
+ const parts = new Map<string, { part: number; content: string }[]>();
45
+ for (const file of files) {
46
+ const match = SPLIT_PART.exec(file.path);
47
+ const base = match === null ? file.path : `${match[1] ?? ''}.sql`;
48
+ const list = parts.get(base) ?? [];
49
+ list.push({ part: match === null ? 0 : Number(match[2]), content: file.content });
50
+ parts.set(base, list);
51
+ }
52
+ const units = [...parts.entries()].map(([path, list]): Unit => {
53
+ const framework = path.startsWith(`${FRAMEWORK_DUMP_DIR}/`);
54
+ const local = framework ? path.slice(FRAMEWORK_DUMP_DIR.length + 1) : path;
55
+ return {
56
+ path,
57
+ kind: local.includes('/') ? (local.split('/')[0] ?? '') : '',
58
+ framework,
59
+ script: list
60
+ .sort((a, b) => a.part - b.part)
61
+ .map((part) => part.content)
62
+ .join(''),
63
+ };
64
+ });
65
+ const key = (unit: Unit): string => `${unit.kind}/${unit.framework ? '0' : '1'}/${unit.path}`;
66
+ return units.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
67
+ }
68
+
69
+ /** "Not created YET" — the three states a statement raises when it names something later in the order. */
70
+ const NOT_YET: ReadonlySet<string> = new Set([
71
+ SQLSTATE.undefinedTable,
72
+ SQLSTATE.undefinedFunction,
73
+ SQLSTATE.undefinedObject,
74
+ ]);
75
+
76
+ const detailOf = (error: unknown): string =>
77
+ error instanceof DbError ? error.cause : renderThrowable(error);
78
+
79
+ /**
80
+ * Load every file. A unit that fails because something it names does not exist yet is rolled back
81
+ * to its savepoint and retried after the rest — kind order is right for nearly every schema, and
82
+ * the exceptions (a view over a view, a column default calling a function) are dependencies no
83
+ * directory layout can order. A pass that loads nothing ends the loop with the last refusal.
84
+ *
85
+ * `check_function_bodies` is off for the transaction: a SQL-language function body is validated
86
+ * at creation, and it may name a table an alphabetical neighbour has not created.
87
+ */
88
+ export async function loadSchemaDump(options: LoadSchemaDumpOptions): Promise<SchemaLoadReport> {
89
+ const client = options.client ?? baseClient();
90
+ let pending = loadOrder(options.files);
91
+ const files = pending.length;
92
+ let statements = 0;
93
+ await withTransaction(
94
+ async (tx) => {
95
+ await tx.execute(raw('set local check_function_bodies = off'));
96
+ while (pending.length > 0) {
97
+ const deferred: Unit[] = [];
98
+ let refusal: DbError | undefined;
99
+ for (const unit of pending) {
100
+ const script = statementsOf(unit.script);
101
+ try {
102
+ await withTransaction(async (savepoint) => {
103
+ for (const statement of script) await savepoint.execute(raw(statement));
104
+ });
105
+ statements += script.length;
106
+ } catch (error) {
107
+ refusal = schemaDumpDrift(unloadableDump(unit.path, detailOf(error)));
108
+ if (!NOT_YET.has(sqlState(error) ?? '')) throw refusal;
109
+ deferred.push(unit);
110
+ }
111
+ }
112
+ if (deferred.length === pending.length && refusal !== undefined) throw refusal;
113
+ pending = deferred;
114
+ }
115
+ },
116
+ { client },
117
+ );
118
+ return { files, statements };
119
+ }
@@ -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
@@ -16,6 +16,9 @@ export const SQLSTATE = Object.freeze({
16
16
  undefinedTable: '42P01',
17
17
  /** `undefined_column` — an entity edited before the migration that adds the column ran. */
18
18
  undefinedColumn: '42703',
19
+ /** `undefined_function` and `undefined_object` — with `undefined_table`, "not created YET". */
20
+ undefinedFunction: '42883',
21
+ undefinedObject: '42704',
19
22
  uniqueViolation: '23505',
20
23
  foreignKeyViolation: '23503',
21
24
  serializationFailure: '40001',
@@ -28,12 +31,6 @@ export const SQLSTATE = Object.freeze({
28
31
  outOfMemory: '53200',
29
32
  } as const);
30
33
 
31
- /** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
32
- const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
33
-
34
- /** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
35
- const MAX_WRAPS = 4;
36
-
37
34
  /** A field off a value that may fight being read — `stringField`'s shape, for a non-string. */
38
35
  function unknownField(value: unknown, key: string): unknown {
39
36
  if (typeof value !== 'object' || value === null) return undefined;
@@ -44,6 +41,32 @@ function unknownField(value: unknown, key: string): unknown {
44
41
  }
45
42
  }
46
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
+
47
70
  /**
48
71
  * The SQLSTATE a driver error carries, unwrapping `DbError.sourceError` on the way, or `undefined`
49
72
  * when the failure never reached the server — a refused socket, a closed pool, a DNS miss.
@@ -54,17 +77,17 @@ function unknownField(value: unknown, key: string): unknown {
54
77
  * has no `errno` at all. Reading `code` alone is correct on the embedded driver and wrong on every
55
78
  * production one, which is exactly the split `isLedgerMissing` was living on.
56
79
  *
57
- * The shape test is what keeps the two apart: `ERR_POSTGRES_SERVER_ERROR` and `X_DB_UNAVAILABLE`
58
- * 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.
59
82
  */
60
83
  export function sqlState(error: unknown): string | undefined {
61
84
  let value = error;
62
85
  for (let depth = 0; depth < MAX_WRAPS; depth += 1) {
63
86
  if (value === undefined || value === null) return undefined;
64
87
  const errno = stringField(value, 'errno');
65
- if (errno !== undefined && SQLSTATE_SHAPE.test(errno)) return errno;
88
+ if (errno !== undefined && isState(value, errno)) return errno;
66
89
  const code = stringField(value, 'code');
67
- if (code !== undefined && SQLSTATE_SHAPE.test(code)) return code;
90
+ if (code !== undefined && isState(value, code)) return code;
68
91
  value = unknownField(value, 'sourceError');
69
92
  }
70
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
+ });
@@ -0,0 +1,122 @@
1
+ // Single responsibility: what a transaction scope is ASKED for and what it hands back — the `DbTx`
2
+ // handle, `TransactionOptions`, and the `BEGIN` text those options spell. Split from
3
+ // `transaction.ts` at the file-size rule; the scope itself (pin, savepoints, COMMIT) stays there.
4
+
5
+ import type { Random } from '@ultimat3/core';
6
+ import type { DbClient } from './client';
7
+ import { isolationLevelInvalid } from './errors';
8
+
9
+ export interface DbTx extends DbClient {
10
+ readonly id: string;
11
+ /**
12
+ * The client this transaction was **opened on** — `options.client`, or `baseClient()`. Not the
13
+ * reservation the statements run on: what a caller needs to know is which database and which
14
+ * pool this scope belongs to, and the pin is an implementation detail of that.
15
+ *
16
+ * It exists because the answer was unanswerable from above. `@ultimat3/entity`'s repositories
17
+ * can be pinned to a specific client (`database(shard)`), and a pinned repository inside
18
+ * `withTransaction` sends its statements to *its own pool* while the `BEGIN` sits on a
19
+ * connection this scope reserved — so the write commits immediately and survives the rollback,
20
+ * and reads inside the transaction cannot see it. `withTransaction(fn, { client: shard })` does
21
+ * not fix it either: the transaction runs on a *reservation* of the shard and the repository
22
+ * still sends to the pool. With nothing to compare against, tier 2's only honest answer was to
23
+ * refuse (`X_REPO_CLIENT_PINNED`). `tx.origin === thePinnedClient` turns that refusal into the
24
+ * case working — the repository joins its own shard's transaction — and leaves the refusal for
25
+ * what it should always have been: a genuine mix of two databases in one scope.
26
+ *
27
+ * A nested scope reports the root's, because a SAVEPOINT belongs to the transaction that opened.
28
+ */
29
+ readonly origin: DbClient;
30
+ /**
31
+ * Fired in reverse registration order when this scope rolls back. Never on commit, and never
32
+ * when the answer to COMMIT was lost: the write it would undo may be durable.
33
+ */
34
+ onRollback(undo: () => void): void;
35
+ /**
36
+ * Fired in registration order once the ROOT transaction has COMMITTED — never on rollback, and
37
+ * never when the answer to COMMIT was lost (`X_DB_COMMIT_UNKNOWN`). A
38
+ * nested scope's effects are handed to its parent on `RELEASE` and dropped on `ROLLBACK TO`, so
39
+ * nothing fires for a write that is not durable. What a change feed, a cache purge or a dev row
40
+ * observer needs: reporting a write before COMMIT reports rows a rollback then erases. An effect
41
+ * that throws is swallowed — the transaction already committed, and nothing can un-commit it.
42
+ */
43
+ onCommit(effect: () => void): void;
44
+ }
45
+
46
+ export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
47
+
48
+ export interface TransactionOptions {
49
+ readonly isolation?: IsolationLevel | undefined;
50
+ readonly readOnly?: boolean | undefined;
51
+ /** Only meaningful with `serializable` + `readOnly`; lets Postgres wait instead of retrying. */
52
+ readonly deferrable?: boolean | undefined;
53
+ /** Override the ambient pool — tests and `x db branch` run against a specific client. */
54
+ readonly client?: DbClient | undefined;
55
+ /**
56
+ * Extra attempts after a `40001`/`40P01`, and **only** after one. Default 0, so adding the option
57
+ * changed no existing transaction's behaviour (axiom 1) — a retry that ran without being asked
58
+ * for would silently double every non-idempotent handler in the framework.
59
+ *
60
+ * Opt in wherever `isolation: 'serializable'` is set: under SERIALIZABLE a serialization failure
61
+ * is normal traffic, not an exception, and until this existed a payments team choosing it for
62
+ * ledger correctness got ~3% of transactions surfacing to the user as "cannot reach the
63
+ * database" with no way to write their own retry, because nothing distinguished `40001` from a
64
+ * dead socket.
65
+ *
66
+ * **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
67
+ * `onRollback` undos fire before each retry, in reverse registration order.
68
+ *
69
+ * Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
70
+ */
71
+ readonly retry?: number | undefined;
72
+ /**
73
+ * The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
74
+ * provable only by waiting for it is a schedule no test pins — and production passes neither.
75
+ * They are only ever read when `retry` is 1 or more.
76
+ */
77
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
78
+ readonly random?: Random | undefined;
79
+ /**
80
+ * How long a NESTED scope waits for a sibling scope to finish before it may open, in
81
+ * milliseconds. Sibling scopes under one parent run one after the other, so a body that awaits
82
+ * a sibling started after it waits for itself; past this the waiting call rejects with
83
+ * `X_DB_SIBLING_SCOPE_TIMEOUT` instead of hanging. Default `SIBLING_SCOPE_WAIT_MS` (30 s); `0`
84
+ * waits without a deadline. Read only by a nested scope — the outermost one has no sibling.
85
+ */
86
+ readonly siblingWaitMs?: number | undefined;
87
+ }
88
+
89
+ /**
90
+ * The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
91
+ * value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
92
+ * no parameters, so this is one of the two statements here built as text, and a level spliced into
93
+ * it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
94
+ * value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
95
+ * `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
96
+ * non-string became an uncoded `TypeError` inside a template literal.
97
+ *
98
+ * The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
99
+ * is a type error here rather than a refusal at runtime.
100
+ */
101
+ const isolationMode = (declared: IsolationLevel): string => {
102
+ switch (declared) {
103
+ case 'read committed':
104
+ return 'ISOLATION LEVEL READ COMMITTED';
105
+ case 'repeatable read':
106
+ return 'ISOLATION LEVEL REPEATABLE READ';
107
+ case 'serializable':
108
+ return 'ISOLATION LEVEL SERIALIZABLE';
109
+ default: {
110
+ const unhandled: never = declared;
111
+ throw isolationLevelInvalid(unhandled);
112
+ }
113
+ }
114
+ };
115
+
116
+ export function beginStatement(options: TransactionOptions): string {
117
+ const modes: string[] = [];
118
+ if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
119
+ if (options.readOnly === true) modes.push('READ ONLY');
120
+ if (options.deferrable === true) modes.push('DEFERRABLE');
121
+ return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
122
+ }