@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.
- package/CLAUDE.md +580 -0
- package/README.md +147 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +10 -4
- package/src/client.ts +241 -25
- package/src/destructive.ts +126 -0
- package/src/drift.ts +256 -13
- package/src/errors.ts +232 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +37 -11
- package/src/introspect.ts +34 -8
- package/src/migrate.ts +272 -63
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
package/src/sql-scan.ts
ADDED
|
@@ -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
|
+
}
|
package/src/sqlstate.ts
ADDED
|
@@ -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
|
+
}
|
package/src/transaction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|