@ultimat3/db 1.2.0 → 2.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.
@@ -0,0 +1,159 @@
1
+ // Single responsibility: name the span of SQL text starting at one offset — a comment, a literal,
2
+ // a quoted identifier, a dollar-quoted body, or none of them. One scanner, because a splitter that
3
+ // disagreed with a guard about where a literal ends is a `;` sent as data or a `delete` read as
4
+ // prose, and the two answers must be the same answer.
5
+
6
+ const IDENTIFIER_START = /[A-Za-z_]/;
7
+ export const IDENTIFIER_PART = /[A-Za-z0-9_]/;
8
+ /** `$` is legal in an identifier after the first character — `a$b` is one name, not three. */
9
+ const IDENTIFIER_TAIL = /[A-Za-z0-9_$]/;
10
+
11
+ export type NoiseKind = 'line-comment' | 'block-comment' | 'string' | 'identifier' | 'dollar-body';
12
+
13
+ /** A span that is not code: what it is, and the offset just past it. */
14
+ export interface NoiseSpan {
15
+ readonly kind: NoiseKind;
16
+ readonly end: number;
17
+ }
18
+
19
+ /** Past a `--` comment, including the newline that ends it. */
20
+ function skipLineComment(script: string, index: number): number {
21
+ const newline = script.indexOf('\n', index);
22
+ return newline === -1 ? script.length : newline + 1;
23
+ }
24
+
25
+ /**
26
+ * Past a block comment. Postgres **nests** them, so the depth is counted rather than matched to
27
+ * the first terminator — a commented-out block that itself contains a comment closes once, and
28
+ * every `;` after that point would otherwise be read as data.
29
+ */
30
+ function skipBlockComment(script: string, index: number): number {
31
+ let depth = 0;
32
+ let at = index;
33
+ while (at < script.length) {
34
+ const char = script[at];
35
+ const next = script[at + 1];
36
+ if (char === '/' && next === '*') {
37
+ depth += 1;
38
+ at += 2;
39
+ continue;
40
+ }
41
+ if (char === '*' && next === '/') {
42
+ depth -= 1;
43
+ at += 2;
44
+ if (depth === 0) return at;
45
+ continue;
46
+ }
47
+ at += 1;
48
+ }
49
+ return script.length;
50
+ }
51
+
52
+ /**
53
+ * Past a run closing on `quote`, where a doubled quote is an escaped one — `'it''s'` and
54
+ * `"a""b"` are each one token. `escapes` is the `E''` dialect, the only one where a backslash
55
+ * escapes the character after it; a standard-conforming string carries it as data.
56
+ */
57
+ function skipQuoted(script: string, index: number, quote: string, escapes: boolean): number {
58
+ let at = index + 1;
59
+ while (at < script.length) {
60
+ const char = script[at];
61
+ if (escapes && char === '\\') {
62
+ at += 2;
63
+ continue;
64
+ }
65
+ if (char === quote) {
66
+ if (script[at + 1] === quote) {
67
+ at += 2;
68
+ continue;
69
+ }
70
+ return at + 1;
71
+ }
72
+ at += 1;
73
+ }
74
+ return script.length;
75
+ }
76
+
77
+ /**
78
+ * Whether the `$` at `index` continues an identifier instead of opening a delimiter.
79
+ *
80
+ * The run before it is walked to its start rather than one character being read, because the
81
+ * answer is what that run *began* as: `foo$tag$` is the single identifier Postgres reads it as
82
+ * (`$` is legal after the first character), while `$1$tag$` is a bound parameter followed by a
83
+ * real delimiter — a run opening with a digit or a `$` cannot be an identifier at all.
84
+ */
85
+ function insideIdentifier(script: string, index: number): boolean {
86
+ let at = index - 1;
87
+ while (at >= 0 && IDENTIFIER_TAIL.test(script[at] ?? '')) at -= 1;
88
+ const first = script[at + 1];
89
+ return at + 1 < index && first !== undefined && IDENTIFIER_START.test(first);
90
+ }
91
+
92
+ /**
93
+ * The `$tag$` opening a dollar-quoted body at `index`, or `null`. A tag is an identifier or
94
+ * empty, which is what keeps a bound parameter out: `$1` cannot open a body, so `where "id" = $1`
95
+ * never swallows the rest of the script.
96
+ *
97
+ * A delimiter also needs separating from the identifier before it, or `select foo$tag$; select
98
+ * 2;` is one statement to us and two to the server — which answers `cannot insert multiple
99
+ * commands into a prepared statement`.
100
+ */
101
+ export function dollarTagAt(script: string, index: number): string | null {
102
+ if (script[index] !== '$') return null;
103
+ if (insideIdentifier(script, index)) return null;
104
+ let at = index + 1;
105
+ while (at < script.length) {
106
+ const char = script[at] ?? '';
107
+ const valid = at === index + 1 ? IDENTIFIER_START.test(char) : IDENTIFIER_PART.test(char);
108
+ if (!valid) break;
109
+ at += 1;
110
+ }
111
+ return script[at] === '$' ? script.slice(index, at + 1) : null;
112
+ }
113
+
114
+ /** Past the body `tag` opened, up to and including the matching close. */
115
+ function skipDollarBody(script: string, index: number, tag: string): number {
116
+ const close = script.indexOf(tag, index + tag.length);
117
+ return close === -1 ? script.length : close + tag.length;
118
+ }
119
+
120
+ /**
121
+ * Whether the `'` at `index` opens an `E''` string. The prefix is a whole token, so a trailing
122
+ * `e` on an identifier does not turn the literal beside it into an escape string.
123
+ */
124
+ function escapesAt(script: string, index: number): boolean {
125
+ const prefix = script[index - 1];
126
+ if (prefix !== 'E' && prefix !== 'e') return false;
127
+ const before = script[index - 2];
128
+ return before === undefined || !IDENTIFIER_PART.test(before);
129
+ }
130
+
131
+ /**
132
+ * The non-code span starting at `index`, or `null` when `index` is code.
133
+ *
134
+ * Source order is the whole point: a caller that asked "is there a comment anywhere" before
135
+ * "where do the literals end" reads the `--` in `select '--'; delete from posts` as a comment and
136
+ * erases a live statement. Walking forward one span at a time cannot make that mistake, because
137
+ * by the time the `--` is reached it is already inside the literal that was scanned first.
138
+ *
139
+ * A span left unterminated ends at the end of the text rather than being refused: Postgres names
140
+ * that syntax error precisely, and a second SQL parser competing with it would only report the
141
+ * same fault in worse words.
142
+ */
143
+ export function noiseAt(script: string, index: number): NoiseSpan | null {
144
+ const char = script[index];
145
+ if (char === '-' && script[index + 1] === '-') {
146
+ return { kind: 'line-comment', end: skipLineComment(script, index) };
147
+ }
148
+ if (char === '/' && script[index + 1] === '*') {
149
+ return { kind: 'block-comment', end: skipBlockComment(script, index) };
150
+ }
151
+ if (char === "'") {
152
+ return { kind: 'string', end: skipQuoted(script, index, char, escapesAt(script, index)) };
153
+ }
154
+ if (char === '"') {
155
+ return { kind: 'identifier', end: skipQuoted(script, index, char, false) };
156
+ }
157
+ const tag = dollarTagAt(script, index);
158
+ return tag === null ? null : { kind: 'dollar-body', end: skipDollarBody(script, index, tag) };
159
+ }
@@ -0,0 +1,107 @@
1
+ // Single responsibility: what the *database* said went wrong. One reader for the SQLSTATE a driver
2
+ // error carries and one closed table from the states this framework can act on to a code. Two
3
+ // drivers spell the field differently, so the read lives here once — a second copy is a second
4
+ // answer to "is this a unique violation".
5
+
6
+ import { stringField } from '@ultimat3/core';
7
+
8
+ /**
9
+ * The SQLSTATEs the framework names. A closed list on purpose: a table enumerating all ~250 of
10
+ * Postgres' classes would be a second copy of the manual, and every entry here has a `fix:` an
11
+ * operator can run. Everything absent is `X_DB_UNAVAILABLE`, which is the honest answer to "the
12
+ * database said something we have no instruction for".
13
+ */
14
+ export const SQLSTATE = Object.freeze({
15
+ /** `undefined_table` — the ledger's absence is a class, not a message to match on. */
16
+ undefinedTable: '42P01',
17
+ uniqueViolation: '23505',
18
+ foreignKeyViolation: '23503',
19
+ serializationFailure: '40001',
20
+ deadlockDetected: '40P01',
21
+ /** `query_canceled` — what `statement_timeout` raises. */
22
+ queryCanceled: '57014',
23
+ /** `lock_not_available` — what `lock_timeout` raises while a DDL statement queues. */
24
+ lockNotAvailable: '55P03',
25
+ tooManyConnections: '53300',
26
+ outOfMemory: '53200',
27
+ } as const);
28
+
29
+ /** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
30
+ const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
31
+
32
+ /** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
33
+ const MAX_WRAPS = 4;
34
+
35
+ /** A field off a value that may fight being read — `stringField`'s shape, for a non-string. */
36
+ function unknownField(value: unknown, key: string): unknown {
37
+ if (typeof value !== 'object' || value === null) return undefined;
38
+ try {
39
+ return (value as Record<string, unknown>)[key];
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The SQLSTATE a driver error carries, unwrapping `DbError.sourceError` on the way, or `undefined`
47
+ * when the failure never reached the server — a refused socket, a closed pool, a DNS miss.
48
+ *
49
+ * **`errno` is read before `code`, and that ordering is the bug this function fixes.** Measured on
50
+ * bun 1.3.14 against Postgres 17: `Bun.SQL` puts `ERR_POSTGRES_SERVER_ERROR` on `code` and the
51
+ * SQLSTATE on `errno`, while PGlite — node-postgres' protocol — puts the SQLSTATE on `code` and
52
+ * has no `errno` at all. Reading `code` alone is correct on the embedded driver and wrong on every
53
+ * production one, which is exactly the split `isLedgerMissing` was living on.
54
+ *
55
+ * The shape test is what keeps the two apart: `ERR_POSTGRES_SERVER_ERROR` and `X_DB_UNAVAILABLE`
56
+ * are not five characters of `[0-9A-Z]`, and no SQLSTATE contains an underscore.
57
+ */
58
+ export function sqlState(error: unknown): string | undefined {
59
+ let value = error;
60
+ for (let depth = 0; depth < MAX_WRAPS; depth += 1) {
61
+ if (value === undefined || value === null) return undefined;
62
+ const errno = stringField(value, 'errno');
63
+ if (errno !== undefined && SQLSTATE_SHAPE.test(errno)) return errno;
64
+ const code = stringField(value, 'code');
65
+ if (code !== undefined && SQLSTATE_SHAPE.test(code)) return code;
66
+ value = unknownField(value, 'sourceError');
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** The codes a SQLSTATE can classify into. `errors.ts` owns their titles and their fixes. */
72
+ export type DbSqlStateCode =
73
+ | 'X_DB_UNIQUE_VIOLATION'
74
+ | 'X_DB_FOREIGN_KEY_VIOLATION'
75
+ | 'X_DB_SERIALIZATION_FAILURE'
76
+ | 'X_DB_STATEMENT_TIMEOUT'
77
+ | 'X_DB_LOCK_TIMEOUT'
78
+ | 'X_DB_POOL_EXHAUSTED';
79
+
80
+ /**
81
+ * SQLSTATE to code, closed. `40P01` (deadlock) joins `40001` because the instruction is identical
82
+ * — re-run the whole transaction — and a caller branching on which of the two it lost to would be
83
+ * writing the same retry twice. `53200` (out_of_memory) joins `53300` for the same reason: both are
84
+ * class 53, insufficient resources, and both are answered by asking for fewer connections.
85
+ */
86
+ export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Object.freeze({
87
+ [SQLSTATE.uniqueViolation]: 'X_DB_UNIQUE_VIOLATION',
88
+ [SQLSTATE.foreignKeyViolation]: 'X_DB_FOREIGN_KEY_VIOLATION',
89
+ [SQLSTATE.serializationFailure]: 'X_DB_SERIALIZATION_FAILURE',
90
+ [SQLSTATE.deadlockDetected]: 'X_DB_SERIALIZATION_FAILURE',
91
+ [SQLSTATE.queryCanceled]: 'X_DB_STATEMENT_TIMEOUT',
92
+ [SQLSTATE.lockNotAvailable]: 'X_DB_LOCK_TIMEOUT',
93
+ [SQLSTATE.tooManyConnections]: 'X_DB_POOL_EXHAUSTED',
94
+ [SQLSTATE.outOfMemory]: 'X_DB_POOL_EXHAUSTED',
95
+ } as const);
96
+
97
+ /** `undefined` when the state is unknown or absent — the caller then reports unavailability. */
98
+ export function sqlStateCode(error: unknown): DbSqlStateCode | undefined {
99
+ const state = sqlState(error);
100
+ return state === undefined ? undefined : DB_SQLSTATE_CODES[state];
101
+ }
102
+
103
+ /** Whether re-running the whole transaction is the documented answer. `withTransaction`'s retry. */
104
+ export function isRetryableState(error: unknown): boolean {
105
+ const state = sqlState(error);
106
+ return state === SQLSTATE.serializationFailure || state === SQLSTATE.deadlockDetected;
107
+ }
@@ -0,0 +1,58 @@
1
+ // Single responsibility: what shape a statement is — the verb it opens with, whether that verb
2
+ // writes, and the identity repeated statements are counted under. Two detectors above this package
3
+ // group statements by that identity (`x dev`'s ledger, the `statements` test fixture) and a third
4
+ // names spans from the same verb, so the rule lives once, next to the `StatementEvent` it reads.
5
+ // Nothing here counts anything: a threshold is a verdict's, and a verdict is `@ultimat3/entity`'s.
6
+
7
+ import type { StatementEvent } from './observe';
8
+
9
+ const LEADING_WORD = /^[A-Za-z]+/;
10
+ const WHITESPACE = /\s+/g;
11
+
12
+ /**
13
+ * The first word, lowercased — `select`, `insert`, `begin` — and `''` when a statement opens with
14
+ * anything else. A text opening with a comment or a parenthesis has no verb, deliberately: this is
15
+ * the one word every statement carries, and stripping comments to find a later one would be a
16
+ * second reading of the SQL — `sql-noise.ts` is the one blanker — for the sake of one label.
17
+ */
18
+ export function statementVerb(text: string): string {
19
+ return (LEADING_WORD.exec(text.trimStart())?.[0] ?? '').toLowerCase();
20
+ }
21
+
22
+ /**
23
+ * The verbs that make a statement a write. A set of verbs and not a set of repository operations:
24
+ * a soft delete is an `update`, an op list would drift with `@ultimat3/entity`'s method names, and
25
+ * hand-written SQL carries no operation at all.
26
+ */
27
+ const WRITE_VERBS: ReadonlySet<string> = new Set([
28
+ 'insert',
29
+ 'update',
30
+ 'delete',
31
+ 'upsert',
32
+ 'merge',
33
+ 'truncate',
34
+ 'copy',
35
+ ]);
36
+
37
+ /**
38
+ * Read or write, decided from the statement rather than from the operation above it, for the same
39
+ * reason `statementSpanName` reads the verb: it is the one fact every statement carries, attributed
40
+ * or not. A statement opening with a CTE reads as a read — naming `insertAll` in a fix for a loop
41
+ * of `with … select` would be wrong more often than naming `preload` for a loop that writes.
42
+ */
43
+ export function statementKind(text: string): 'read' | 'write' {
44
+ return WRITE_VERBS.has(statementVerb(text)) ? 'write' : 'read';
45
+ }
46
+
47
+ /**
48
+ * The identity a loop repeats. An attributed statement groups by `entity.op`, because
49
+ * `members.findById` fifty times is the report an author can act on and the SQL is one sample of
50
+ * it — which is the whole reason `withStatementAttribution` exists. Everything else groups by its
51
+ * own text, already `$n`-parameterized by `sql()`, with whitespace collapsed so a builder that
52
+ * indents differently between two calls is still one shape rather than two.
53
+ */
54
+ export function statementFingerprint(event: StatementEvent): string {
55
+ const attribution = event.attribution;
56
+ if (attribution !== undefined) return `${attribution.entity}.${attribution.op}`;
57
+ return event.text.replace(WHITESPACE, ' ').trim();
58
+ }
@@ -0,0 +1,40 @@
1
+ // Single responsibility: the span one statement is. Named `db.<verb>` because `x dev`'s recorder
2
+ // reads the panel's kind off the prefix, exactly as it does for `query.`, `cache.` and `job.`, and
3
+ // carrying the text as `db.statement` — the attribute the timeline groups on to count an N+1. It is
4
+ // opened on the observed path only, so a process with no diagnostic installed traces what it did
5
+ // before the seam existed.
6
+
7
+ import { withSpan } from '@ultimat3/core';
8
+ import { statementVerb } from './statement-shape';
9
+
10
+ /**
11
+ * OTel's name for the statement itself. Exported because it is a contract across two packages, not
12
+ * a local constant: `packages/cli/src/dev-traces.ts` reads it as a span's detail, so a rename that
13
+ * only landed here would leave the timeline grouping span names again with every test still green.
14
+ */
15
+ export const STATEMENT_ATTRIBUTE = 'db.statement';
16
+
17
+ /**
18
+ * `db.select`, `db.insert`, `db.begin` — low cardinality on purpose, so the flame reads at a glance
19
+ * and a trace backend can still aggregate by name. The full text rides on the span, never in it.
20
+ *
21
+ * The verb is `statement-shape.ts`'s, the same read the N+1 detectors classify a statement with: a
22
+ * text with no leading word is `db.statement` here and a read there, one scanner and two labels.
23
+ */
24
+ export function statementSpanName(text: string): string {
25
+ const verb = statementVerb(text);
26
+ return `db.${verb === '' ? 'statement' : verb}`;
27
+ }
28
+
29
+ /**
30
+ * Wraps the send, so the span's duration is the statement's and a failure is recorded on it rather
31
+ * than inferred from the gap after it. `client` is the OTel kind — the database is the remote peer;
32
+ * the panel's own `sql` kind comes off the name prefix, since `db` is tier 1 and cannot name a
33
+ * tier-5 vocabulary.
34
+ */
35
+ export function withStatementSpan<T>(text: string, send: () => Promise<T>): Promise<T> {
36
+ return withSpan(statementSpanName(text), send, {
37
+ kind: 'client',
38
+ attributes: { [STATEMENT_ATTRIBUTE]: text },
39
+ });
40
+ }
@@ -0,0 +1,51 @@
1
+ // Single responsibility: cut a SQL script into the statements a driver sends one at a time —
2
+ // one send is one statement, or the server answers `cannot insert multiple commands into a
3
+ // prepared statement`. Where a `;` separates and where it is data is `sql-scan.ts`'s answer.
4
+
5
+ import { noiseAt } from './sql-scan';
6
+
7
+ const WHITESPACE = /\s/;
8
+
9
+ const isComment = (kind: string): boolean => kind === 'line-comment' || kind === 'block-comment';
10
+
11
+ /**
12
+ * The statements of `script`, in order, each without its separator.
13
+ *
14
+ * A chunk holding only whitespace and comments is **not** a statement and is dropped: an empty
15
+ * `up`, or one whose tail is the `-- backfill …, then: …;` note `generateMigration` emits, would
16
+ * otherwise reach the driver as an empty query.
17
+ */
18
+ export function statementsOf(script: string): readonly string[] {
19
+ const statements: string[] = [];
20
+ let start = 0;
21
+ let index = 0;
22
+ // Set by anything that is not whitespace and not inside a comment: what makes a chunk a
23
+ // statement rather than a note between two of them.
24
+ let content = false;
25
+
26
+ const cut = (end: number): void => {
27
+ const text = content ? script.slice(start, end).trim() : '';
28
+ if (text.length > 0) statements.push(text);
29
+ content = false;
30
+ };
31
+
32
+ while (index < script.length) {
33
+ const noise = noiseAt(script, index);
34
+ if (noise !== null) {
35
+ if (!isComment(noise.kind)) content = true;
36
+ index = noise.end;
37
+ continue;
38
+ }
39
+ const char = script[index] ?? '';
40
+ if (char === ';') {
41
+ cut(index);
42
+ start = index + 1;
43
+ index += 1;
44
+ continue;
45
+ }
46
+ if (!WHITESPACE.test(char)) content = true;
47
+ index += 1;
48
+ }
49
+ cut(script.length);
50
+ return statements;
51
+ }
@@ -4,12 +4,33 @@
4
4
  // to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
5
5
 
6
6
  import { AsyncLocalStorage } from 'node:async_hooks';
7
- import { nanoid } from '@ultimat3/core';
7
+ import { assert, nanoid } from '@ultimat3/core';
8
8
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
9
+ import { serializationExhausted } from './errors';
9
10
  import { raw, type SqlFragment } from './sql';
11
+ import { isRetryableState } from './sqlstate';
10
12
 
11
13
  export interface DbTx extends DbClient {
12
14
  readonly id: string;
15
+ /**
16
+ * The client this transaction was **opened on** — `options.client`, or `baseClient()`. Not the
17
+ * reservation the statements run on: what a caller needs to know is which database and which
18
+ * pool this scope belongs to, and the pin is an implementation detail of that.
19
+ *
20
+ * It exists because the answer was unanswerable from above. `@ultimat3/entity`'s repositories
21
+ * can be pinned to a specific client (`database(shard)`), and a pinned repository inside
22
+ * `withTransaction` sends its statements to *its own pool* while the `BEGIN` sits on a
23
+ * connection this scope reserved — so the write commits immediately and survives the rollback,
24
+ * and reads inside the transaction cannot see it. `withTransaction(fn, { client: shard })` does
25
+ * not fix it either: the transaction runs on a *reservation* of the shard and the repository
26
+ * still sends to the pool. With nothing to compare against, tier 2's only honest answer was to
27
+ * refuse (`X_REPO_CLIENT_PINNED`). `tx.origin === thePinnedClient` turns that refusal into the
28
+ * case working — the repository joins its own shard's transaction — and leaves the refusal for
29
+ * what it should always have been: a genuine mix of two databases in one scope.
30
+ *
31
+ * A nested scope reports the root's, because a SAVEPOINT belongs to the transaction that opened.
32
+ */
33
+ readonly origin: DbClient;
13
34
  /** Fired in reverse registration order when this scope rolls back. Never on commit. */
14
35
  onRollback(undo: () => void): void;
15
36
  }
@@ -23,6 +44,21 @@ export interface TransactionOptions {
23
44
  readonly deferrable?: boolean | undefined;
24
45
  /** Override the ambient pool — tests and `x db branch` run against a specific client. */
25
46
  readonly client?: DbClient | undefined;
47
+ /**
48
+ * Extra attempts after a `40001`/`40P01`, and **only** after one. Default 0, so adding the option
49
+ * changed no existing transaction's behaviour (axiom 1) — a retry that ran without being asked
50
+ * for would silently double every non-idempotent handler in the framework.
51
+ *
52
+ * Opt in wherever `isolation: 'serializable'` is set: under SERIALIZABLE a serialization failure
53
+ * is normal traffic, not an exception, and until this existed a payments team choosing it for
54
+ * ledger correctness got ~3% of transactions surfacing to the user as "cannot reach the
55
+ * database" with no way to write their own retry, because nothing distinguished `40001` from a
56
+ * dead socket.
57
+ *
58
+ * **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
59
+ * `onRollback` undos fire before each retry, in reverse registration order.
60
+ */
61
+ readonly retry?: number | undefined;
26
62
  }
27
63
 
28
64
  interface TxState {
@@ -31,6 +67,16 @@ interface TxState {
31
67
  readonly undos: (() => void)[];
32
68
  /** Shared by reference across nesting levels so savepoint names never collide. */
33
69
  readonly savepoints: { value: number };
70
+ /**
71
+ * Whether the scope is still OPEN. Shared by reference across nesting for the same reason the
72
+ * savepoint counter is: a SAVEPOINT lives and dies with the root transaction that opened it.
73
+ *
74
+ * Mutable because the store outlives the scope. `AsyncLocalStorage` propagates into every
75
+ * promise chain started inside `fn`, so a statement the app forgot to `await` still finds this
76
+ * store long after COMMIT — and a reader that treats the store's PRESENCE as an open
77
+ * transaction believes a dead one is live.
78
+ */
79
+ readonly live: { value: boolean };
34
80
  }
35
81
 
36
82
  const storage = new AsyncLocalStorage<TxState>();
@@ -40,6 +86,19 @@ export function currentTx(): DbTx | undefined {
40
86
  return storage.getStore()?.tx;
41
87
  }
42
88
 
89
+ /**
90
+ * Is a transaction still OPEN on this async context? A different question from `currentTx() !==
91
+ * undefined`, which only says a store is present — and the store survives the scope. The one
92
+ * reader is `pglite.ts`'s `run()`, where the answer decides whether a statement may skip the
93
+ * single session's turn queue; skipping it on a *closed* transaction is how a straggler landed
94
+ * inside whichever unit of work held the connection next, committed with it, with nothing to read.
95
+ * `currentTx()` deliberately still answers with the dead handle: its statements go through the
96
+ * reservation, whose own `held` fence already re-queues them.
97
+ */
98
+ export function inLiveTx(): boolean {
99
+ return storage.getStore()?.live.value === true;
100
+ }
101
+
43
102
  export function beginStatement(options: TransactionOptions): string {
44
103
  const modes: string[] = [];
45
104
  if (options.isolation !== undefined) {
@@ -50,9 +109,10 @@ export function beginStatement(options: TransactionOptions): string {
50
109
  return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
51
110
  }
52
111
 
53
- function makeTx(id: string, connection: DbClient, undos: (() => void)[]): DbTx {
112
+ function makeTx(id: string, connection: DbClient, undos: (() => void)[], origin: DbClient): DbTx {
54
113
  return {
55
114
  id,
115
+ origin,
56
116
  query: <T>(fragment: SqlFragment) => connection.query<T>(fragment),
57
117
  one: <T>(fragment: SqlFragment) => connection.one<T>(fragment),
58
118
  execute: (fragment: SqlFragment) => connection.execute(fragment),
@@ -77,7 +137,11 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
77
137
  outer.savepoints.value += 1;
78
138
  const name = `x_sp_${outer.savepoints.value}`;
79
139
  const undos: (() => void)[] = [];
80
- const tx = makeTx(`${outer.tx.id}/${name}`, outer.connection, undos);
140
+ const tx = makeTx(`${outer.tx.id}/${name}`, outer.connection, undos, outer.tx.origin);
141
+ // `SAVEPOINT` and `RELEASE` are deliberately uncaught: a savepoint that was never taken means
142
+ // this scope never opened, and a release that failed means its work is not durable in the outer
143
+ // one. Both are the caller's failure to see — swallowing either would run the rest of the unit
144
+ // of work against a transaction that is not the one it thinks it is in.
81
145
  await outer.connection.execute(raw(`SAVEPOINT ${name}`));
82
146
  try {
83
147
  const result = await storage.run({ ...outer, tx, undos }, () => fn(tx));
@@ -87,38 +151,96 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
87
151
  outer.undos.push(...undos);
88
152
  return result;
89
153
  } catch (error) {
90
- await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
154
+ // Best-effort, exactly like the root's ROLLBACK: the savepoint is already gone when the
155
+ // failure was the connection itself, and the caller needs the error that caused the rollback,
156
+ // never the rollback's own.
157
+ await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`)).catch(() => undefined);
91
158
  runUndos(undos);
92
159
  throw error;
93
160
  }
94
161
  }
95
162
 
96
- export async function withTransaction<T>(
97
- fn: (tx: DbTx) => Promise<T>,
98
- options: TransactionOptions = {},
99
- ): Promise<T> {
100
- const outer = storage.getStore();
101
- if (outer !== undefined) return runNested(outer, fn);
102
-
163
+ /**
164
+ * One attempt at a root transaction: its own pin, its own BEGIN, its own undo list. Extracted so
165
+ * the retry loop can re-run it whole — a retry that reused the pin would be re-running against a
166
+ * connection whose transaction is already gone.
167
+ */
168
+ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOptions): Promise<T> {
103
169
  const client = options.client ?? baseClient();
104
- const reserved: DbConnection | undefined = isReservable(client)
170
+ // A pooled BEGIN that lands on a different physical connection than the statements after it is
171
+ // not a transaction at all, so a reservable client pins one connection for the whole scope.
172
+ // Held by a `using` declaration rather than a `finally`, because a `finally` only covers what
173
+ // someone remembered to put in its `try`: BEGIN used to sit above the block, so a rejected BEGIN
174
+ // returned the pin to nobody — on PGlite, the single session's turn with it, wedging every later
175
+ // statement in the process. The declaration covers every exit, including the ones nobody wrote.
176
+ using reserved: DbConnection | undefined = isReservable(client)
105
177
  ? await client.reserve()
106
178
  : undefined;
107
179
  const connection: DbClient = reserved ?? client;
108
180
  const undos: (() => void)[] = [];
109
- const tx = makeTx(`tx_${nanoid(12)}`, connection, undos);
181
+ const tx = makeTx(`tx_${nanoid(12)}`, connection, undos, client);
182
+ // Each attempt gets its own state, and therefore its own `live` — a retry re-runs `fn` against a
183
+ // transaction that is genuinely new, so the abandoned attempt's stragglers must read as closed.
184
+ const state: TxState = { tx, connection, undos, savepoints: { value: 0 }, live: { value: true } };
110
185
 
111
- await connection.execute(raw(beginStatement(options)));
112
186
  try {
113
- const state: TxState = { tx, connection, undos, savepoints: { value: 0 } };
187
+ await connection.execute(raw(beginStatement(options)));
114
188
  const result = await storage.run(state, () => fn(tx));
115
189
  await connection.execute(raw('COMMIT'));
116
190
  return result;
117
191
  } catch (error) {
192
+ // Best-effort: the caller needs the original failure, never the rollback's. A BEGIN that
193
+ // itself failed opened nothing, so this ROLLBACK is a no-op the server answers with a notice.
118
194
  await connection.execute(raw('ROLLBACK')).catch(() => undefined);
119
195
  runUndos(undos);
120
196
  throw error;
121
197
  } finally {
122
- reserved?.release();
198
+ // The scope says when it CLOSED, on every exit, because nothing else can: the store it left
199
+ // behind is indistinguishable from a live one, and `inLiveTx()` is what tells them apart.
200
+ // Cleared before the `using` pin is given back, so no window exists where a straggler could
201
+ // still be sent direct at a connection this scope no longer owns.
202
+ state.live.value = false;
203
+ }
204
+ }
205
+
206
+ export async function withTransaction<T>(
207
+ fn: (tx: DbTx) => Promise<T>,
208
+ options: TransactionOptions = {},
209
+ ): Promise<T> {
210
+ const outer = storage.getStore();
211
+ if (outer !== undefined) {
212
+ // A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for:
213
+ // measured against Postgres 17, a `40001` aborts the **whole** transaction, so the
214
+ // `ROLLBACK TO SAVEPOINT` that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT
215
+ // can only be used in transaction blocks`. Re-running the inner body would also be re-running
216
+ // it against reads the outer scope took before the race — the retry has to own the BEGIN.
217
+ // Refused rather than ignored: a budget silently dropped is worse than one refused, because
218
+ // the author believes they have it.
219
+ assert(
220
+ options.retry === undefined || options.retry === 0,
221
+ 'withTransaction({ retry }) inside another transaction: a nested scope is a SAVEPOINT, and a serialization failure aborts the whole transaction, so there is nothing left to retry into',
222
+ "move the retry to the OUTERMOST withTransaction — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and drop it here",
223
+ );
224
+ return runNested(outer, fn);
225
+ }
226
+
227
+ const attempts = (options.retry ?? 0) + 1;
228
+ let last: unknown;
229
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
230
+ try {
231
+ return await runRoot(fn, options);
232
+ } catch (error) {
233
+ // Only a lost serialization race, and only that: everything else — a constraint, a timeout, a
234
+ // dead socket, a throw from `fn` itself — is a failure re-running cannot change, and retrying
235
+ // it would turn one error into `retry + 1` of them.
236
+ if (!isRetryableState(error)) throw error;
237
+ // Nobody asked for a retry, so nothing was exhausted: the caller gets the driver's own
238
+ // `X_DB_SERIALIZATION_FAILURE`, whose fix is `withTransaction(fn, { retry: 3 })` — the
239
+ // instruction they actually need. Wrapping it would answer "raise your budget" to someone
240
+ // who has no budget.
241
+ if (attempts === 1) throw error;
242
+ last = error;
243
+ }
123
244
  }
245
+ throw serializationExhausted(attempts, last);
124
246
  }