@ultimat3/db 1.2.0 → 3.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,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
  }
@@ -0,0 +1,29 @@
1
+ // Compile-time pins for this package's disposable resources. Source, not a `.test.ts`, on
2
+ // purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
3
+ // type-level assertion written there can never fail. This module emits nothing and exports
4
+ // nothing anybody imports — a regression is a build error, the only enforcement that counts
5
+ // (axiom 3). `DbConnection` and `Turn` both went through a session where `release()`/`close()`
6
+ // left a resource cached or unreturned; the fix each time was RAII (`Disposable` + `using`), and
7
+ // this pin is what stops a future edit from quietly dropping `Disposable` off either interface —
8
+ // the one place a regression here would otherwise surface is a leaked connection under load, not
9
+ // a red test.
10
+
11
+ import type { DbConnection } from './client';
12
+ import type { Turn } from './pglite-turns';
13
+
14
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
15
+ type Assert<T extends true> = T;
16
+
17
+ /**
18
+ * The pinned handle `client.reserve()` returns must stay `Disposable`, or `using connection =
19
+ * await client.reserve()` in `transaction.ts` / `readonly-query.ts` stops compiling as a
20
+ * scope-bound resource and degrades silently back into a hand-rolled `try`/`finally`.
21
+ */
22
+ export type _DbConnectionIsDisposable = Assert<[DbConnection] extends [Disposable] ? true : false>;
23
+
24
+ /**
25
+ * PGlite's single-session turn must stay `Disposable` too, or `TurnQueue.run()`'s `using turn =
26
+ * await this.take()` in `pglite-turns.ts` loses the same guarantee — the connection never gets
27
+ * queued back to the next waiter on a throw.
28
+ */
29
+ export type _TurnIsDisposable = Assert<[Turn] extends [Disposable] ? true : false>;
package/src/readonly.ts DELETED
@@ -1,111 +0,0 @@
1
- // Single responsibility: a `DbClient` that cannot mutate — for any caller that cannot open its
2
- // own transaction. An LLM with a Postgres connection and no gate is an outage waiting to be
3
- // prompted into existence. (MCP's `db.query` reaches past this for the stronger `readOnlyQuery`:
4
- // a SELECT-only role inside `BEGIN READ ONLY`, where Postgres refuses the write, not a regex.)
5
- // Detection strips comments and string literals first, because
6
- // `/* x */ update ...` and `WITH t AS (INSERT ...) SELECT` are exactly how a naive check is beaten.
7
-
8
- import type { DbClient } from './client';
9
- import { readonlyViolation } from './errors';
10
- import { raw, type SqlFragment } from './sql';
11
-
12
- const MUTATING = [
13
- 'insert',
14
- 'update',
15
- 'delete',
16
- 'truncate',
17
- 'drop',
18
- 'alter',
19
- 'create',
20
- 'grant',
21
- 'revoke',
22
- 'copy',
23
- 'set',
24
- 'call',
25
- 'do',
26
- 'refresh',
27
- 'vacuum',
28
- 'reindex',
29
- 'cluster',
30
- 'lock',
31
- 'merge',
32
- 'analyze',
33
- 'prepare',
34
- 'execute',
35
- ] as const;
36
-
37
- const MUTATING_PATTERN = new RegExp(`\\b(${MUTATING.join('|')})\\b`, 'i');
38
-
39
- /**
40
- * Blank out anything a keyword could legitimately hide inside: line comments, block comments,
41
- * single-quoted literals, dollar-quoted bodies and quoted identifiers. Blanking (rather than
42
- * deleting) keeps offsets stable so the reported statement still reads correctly.
43
- */
44
- export function stripSqlNoise(text: string): string {
45
- return text
46
- .replace(/\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1?\$/g, ' ')
47
- .replace(/--[^\n]*/g, ' ')
48
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
49
- .replace(/'(?:[^']|'')*'/g, " '' ")
50
- .replace(/"(?:[^"]|"")*"/g, ' "" ');
51
- }
52
-
53
- export interface MutationVerdict {
54
- readonly mutating: boolean;
55
- readonly keyword: string | null;
56
- }
57
-
58
- /**
59
- * Whole-text scan, not a leading-keyword check: multi-statement strings and CTEs that end in a
60
- * writing branch must both be caught, and a false positive here is far cheaper than a false
61
- * negative. `updated_at` and `offset` do not match — `\b` requires a non-word boundary.
62
- */
63
- export function inspectStatement(text: string): MutationVerdict {
64
- const match = MUTATING_PATTERN.exec(stripSqlNoise(text));
65
- if (match === null) return { mutating: false, keyword: null };
66
- return { mutating: true, keyword: match[1] ?? match[0] };
67
- }
68
-
69
- export function assertReadOnly(fragment: SqlFragment): void {
70
- const verdict = inspectStatement(fragment.text);
71
- if (!verdict.mutating) return;
72
- throw readonlyViolation(fragment.text.trim().slice(0, 160), verdict.keyword ?? 'mutating');
73
- }
74
-
75
- export interface ReadOnlyOptions {
76
- /** Also ask Postgres to enforce it. Off only for clients that cannot run `SET TRANSACTION`. */
77
- readonly seal?: boolean | undefined;
78
- }
79
-
80
- /**
81
- * Belt and braces: the regex is the gate, `SET TRANSACTION READ ONLY` is the backstop for
82
- * anything the regex was too clever to catch. Sealing is best-effort — outside a transaction
83
- * block Postgres only warns, and a driver that rejects it must not break every read.
84
- */
85
- export function readOnly(client: DbClient, options: ReadOnlyOptions = {}): DbClient {
86
- let sealed = options.seal === false;
87
-
88
- async function seal(): Promise<void> {
89
- if (sealed) return;
90
- sealed = true;
91
- await client.execute(raw('SET TRANSACTION READ ONLY')).catch(() => undefined);
92
- }
93
-
94
- return {
95
- async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
96
- assertReadOnly(fragment);
97
- await seal();
98
- return client.query<T>(fragment);
99
- },
100
- async one<T>(fragment: SqlFragment): Promise<T | null> {
101
- assertReadOnly(fragment);
102
- await seal();
103
- return client.one<T>(fragment);
104
- },
105
- async execute(fragment: SqlFragment): Promise<number> {
106
- assertReadOnly(fragment);
107
- await seal();
108
- return client.execute(fragment);
109
- },
110
- };
111
- }