@ultimat3/db 23.0.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +62 -65
- package/README.md +42 -8
- package/package.json +2 -2
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/catalog-fold.ts +4 -1
- package/src/catalog-objects.ts +29 -0
- package/src/catalog.ts +10 -2
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +209 -59
- package/src/drift.ts +19 -13
- package/src/errors.ts +6 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +5 -3
- package/src/introspect-catalog.ts +18 -1
- package/src/introspect.ts +45 -8
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +77 -20
- package/src/pglite-branch.ts +6 -6
- package/src/pglite.ts +13 -1
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-table.ts +4 -1
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +30 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
package/src/statement-funnel.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
7
|
import { encodeBoundParameters } from './bound-parameters';
|
|
8
8
|
import type { BunSqlDriver } from './bun-sql';
|
|
9
|
+
import { refuseRolledBackCommit } from './commit-tag';
|
|
9
10
|
import { driverError } from './errors';
|
|
10
11
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
11
12
|
import { statementObserver } from './observe';
|
|
@@ -32,12 +33,18 @@ async function sendOn(
|
|
|
32
33
|
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
33
34
|
fragment: SqlFragment,
|
|
34
35
|
): Promise<unknown> {
|
|
36
|
+
// `encodeBoundParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
|
|
37
|
+
// with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
|
|
38
|
+
// `toString()`. One encoder here rather than one import per call site, because this is the
|
|
39
|
+
// only place this driver's `unsafe` is called.
|
|
40
|
+
//
|
|
41
|
+
// ABOVE the `try`: its refusals are this package's own and already coded. Inside it, a ragged
|
|
42
|
+
// array or an Invalid Date came back as `X_DB_UNAVAILABLE` — "set DATABASE_URL" — from a driver
|
|
43
|
+
// that was never called.
|
|
44
|
+
const values = encodeBoundParameters(fragment.values);
|
|
45
|
+
let result: unknown;
|
|
35
46
|
try {
|
|
36
|
-
|
|
37
|
-
// with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
|
|
38
|
-
// `toString()`. One encoder here rather than one import per call site, because this is the
|
|
39
|
-
// only place this driver's `unsafe` is called.
|
|
40
|
-
return await driver.unsafe(fragment.text, encodeBoundParameters(fragment.values));
|
|
47
|
+
result = await driver.unsafe(fragment.text, values);
|
|
41
48
|
} catch (error) {
|
|
42
49
|
// `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
|
|
43
50
|
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
@@ -45,6 +52,10 @@ async function sendOn(
|
|
|
45
52
|
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
46
53
|
throw driverError(statementExcerpt(fragment.text), error);
|
|
47
54
|
}
|
|
55
|
+
// Outside the `try`: this refusal is already typed, and `driverError` would re-wrap it as a
|
|
56
|
+
// database nobody could reach.
|
|
57
|
+
refuseRolledBackCommit(fragment.text, result);
|
|
58
|
+
return result;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: the two refusals a transaction's END can raise — the server rolled it back
|
|
2
|
+
// while the body carried on, and the answer to COMMIT never arrived. Split from `errors.ts` at the
|
|
3
|
+
// file-size rule; both are thrown by `transaction.ts`, the first by the two statement funnels too.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { DbError } from './errors';
|
|
7
|
+
import { sqlState } from './sqlstate';
|
|
8
|
+
|
|
9
|
+
const ABORTED_FIX =
|
|
10
|
+
'await withTransaction(() => fallible()).catch(fallback) # a nested scope is a SAVEPOINT, so ' +
|
|
11
|
+
'only it rolls back — or rethrow the statement error instead of catching it';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Postgres aborts the WHOLE transaction on any statement error and answers the `COMMIT` that
|
|
15
|
+
* follows with the tag `ROLLBACK` and no error, so a body that caught the failure and returned was
|
|
16
|
+
* reported committed with nothing stored. `first` is the statement the body threw away — rendered
|
|
17
|
+
* into the cause, deliberately NOT chained as `sourceError`: `sqlState()` unwraps that chain, and a
|
|
18
|
+
* caller asking "was this a unique violation" would be answered yes by an error that means the
|
|
19
|
+
* whole unit of work is gone.
|
|
20
|
+
*/
|
|
21
|
+
export const transactionAborted = (first?: unknown): DbError => {
|
|
22
|
+
const state = sqlState(first);
|
|
23
|
+
return new DbError({
|
|
24
|
+
code: 'X_DB_TRANSACTION_ABORTED',
|
|
25
|
+
cause:
|
|
26
|
+
first === undefined
|
|
27
|
+
? 'COMMIT was answered with ROLLBACK: a statement failed earlier in this transaction, its error was caught, and the server had already rolled the whole unit of work back'
|
|
28
|
+
: `a statement failed inside the transaction and the error was caught rather than rethrown, so the server rolled the whole unit of work back: ${renderThrowable(first)}`,
|
|
29
|
+
fix: ABORTED_FIX,
|
|
30
|
+
...(state === undefined ? {} : { meta: { sqlState: state } }),
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A nested scope gave up waiting for the sibling holding the turn. Names both: the scope that
|
|
36
|
+
* waited is identified by its parent (it never opened, so it has no savepoint of its own), and the
|
|
37
|
+
* holder by the savepoint it is inside. Terminal: the usual cause is a body awaiting a sibling
|
|
38
|
+
* queued behind it, and the same call made again waits for the same cycle.
|
|
39
|
+
*/
|
|
40
|
+
export const siblingScopeTimeout = (
|
|
41
|
+
parent: string,
|
|
42
|
+
holder: string | undefined,
|
|
43
|
+
waitedMs: number,
|
|
44
|
+
): DbError =>
|
|
45
|
+
new DbError({
|
|
46
|
+
code: 'X_DB_SIBLING_SCOPE_TIMEOUT',
|
|
47
|
+
cause: `a nested scope under ${parent} waited ${waitedMs}ms for its sibling ${holder ?? 'scope'} to finish and never got a turn — sibling scopes run one after the other, so a body that awaits a sibling started after it waits for itself`,
|
|
48
|
+
fix: 'await withTransaction(first); await withTransaction(second) # one after the other, never a nested body awaiting a sibling — or raise the wait: withTransaction(fn, { siblingWaitMs: 120000 })',
|
|
49
|
+
meta: { parent, waitedMs, ...(holder === undefined ? {} : { holder }) },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `COMMIT` was sent and rejected with no SQLSTATE — the socket went before the answer did. The
|
|
54
|
+
* transaction is durable or it is not, and nothing on this side can say which, so neither list
|
|
55
|
+
* runs: an `onRollback` undo would revert state the database may have kept, and an `onCommit`
|
|
56
|
+
* effect would announce rows it may not have.
|
|
57
|
+
*/
|
|
58
|
+
export const commitUnknown = (sourceError: unknown): DbError =>
|
|
59
|
+
new DbError({
|
|
60
|
+
code: 'X_DB_COMMIT_UNKNOWN',
|
|
61
|
+
cause: `the connection failed while COMMIT was in flight, so the transaction is either durable or rolled back and this process cannot tell which; neither onCommit effects nor onRollback undos ran. Only the data can say which — a row the transaction wrote is present if it committed and absent if it did not: ${renderThrowable(sourceError)}`,
|
|
62
|
+
// A session, never a `-c "<placeholder>"`: which row proves it is the caller's knowledge, and
|
|
63
|
+
// a placeholder inside a command is a command that does not run.
|
|
64
|
+
fix: 'psql "$DATABASE_URL" # a session on that database: select a row the transaction wrote, and re-run the unit of work only when it is absent',
|
|
65
|
+
sourceError,
|
|
66
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Single responsibility: what a transaction scope is ASKED for and what it hands back — the `DbTx`
|
|
2
|
+
// handle, `TransactionOptions`, and the `BEGIN` text those options spell. Split from
|
|
3
|
+
// `transaction.ts` at the file-size rule; the scope itself (pin, savepoints, COMMIT) stays there.
|
|
4
|
+
|
|
5
|
+
import type { Random } from '@ultimat3/core';
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { isolationLevelInvalid } from './errors';
|
|
8
|
+
|
|
9
|
+
export interface DbTx extends DbClient {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/**
|
|
12
|
+
* The client this transaction was **opened on** — `options.client`, or `baseClient()`. Not the
|
|
13
|
+
* reservation the statements run on: what a caller needs to know is which database and which
|
|
14
|
+
* pool this scope belongs to, and the pin is an implementation detail of that.
|
|
15
|
+
*
|
|
16
|
+
* It exists because the answer was unanswerable from above. `@ultimat3/entity`'s repositories
|
|
17
|
+
* can be pinned to a specific client (`database(shard)`), and a pinned repository inside
|
|
18
|
+
* `withTransaction` sends its statements to *its own pool* while the `BEGIN` sits on a
|
|
19
|
+
* connection this scope reserved — so the write commits immediately and survives the rollback,
|
|
20
|
+
* and reads inside the transaction cannot see it. `withTransaction(fn, { client: shard })` does
|
|
21
|
+
* not fix it either: the transaction runs on a *reservation* of the shard and the repository
|
|
22
|
+
* still sends to the pool. With nothing to compare against, tier 2's only honest answer was to
|
|
23
|
+
* refuse (`X_REPO_CLIENT_PINNED`). `tx.origin === thePinnedClient` turns that refusal into the
|
|
24
|
+
* case working — the repository joins its own shard's transaction — and leaves the refusal for
|
|
25
|
+
* what it should always have been: a genuine mix of two databases in one scope.
|
|
26
|
+
*
|
|
27
|
+
* A nested scope reports the root's, because a SAVEPOINT belongs to the transaction that opened.
|
|
28
|
+
*/
|
|
29
|
+
readonly origin: DbClient;
|
|
30
|
+
/**
|
|
31
|
+
* Fired in reverse registration order when this scope rolls back. Never on commit, and never
|
|
32
|
+
* when the answer to COMMIT was lost: the write it would undo may be durable.
|
|
33
|
+
*/
|
|
34
|
+
onRollback(undo: () => void): void;
|
|
35
|
+
/**
|
|
36
|
+
* Fired in registration order once the ROOT transaction has COMMITTED — never on rollback, and
|
|
37
|
+
* never when the answer to COMMIT was lost (`X_DB_COMMIT_UNKNOWN`). A
|
|
38
|
+
* nested scope's effects are handed to its parent on `RELEASE` and dropped on `ROLLBACK TO`, so
|
|
39
|
+
* nothing fires for a write that is not durable. What a change feed, a cache purge or a dev row
|
|
40
|
+
* observer needs: reporting a write before COMMIT reports rows a rollback then erases. An effect
|
|
41
|
+
* that throws is swallowed — the transaction already committed, and nothing can un-commit it.
|
|
42
|
+
*/
|
|
43
|
+
onCommit(effect: () => void): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
|
|
47
|
+
|
|
48
|
+
export interface TransactionOptions {
|
|
49
|
+
readonly isolation?: IsolationLevel | undefined;
|
|
50
|
+
readonly readOnly?: boolean | undefined;
|
|
51
|
+
/** Only meaningful with `serializable` + `readOnly`; lets Postgres wait instead of retrying. */
|
|
52
|
+
readonly deferrable?: boolean | undefined;
|
|
53
|
+
/** Override the ambient pool — tests and `x db branch` run against a specific client. */
|
|
54
|
+
readonly client?: DbClient | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Extra attempts after a `40001`/`40P01`, and **only** after one. Default 0, so adding the option
|
|
57
|
+
* changed no existing transaction's behaviour (axiom 1) — a retry that ran without being asked
|
|
58
|
+
* for would silently double every non-idempotent handler in the framework.
|
|
59
|
+
*
|
|
60
|
+
* Opt in wherever `isolation: 'serializable'` is set: under SERIALIZABLE a serialization failure
|
|
61
|
+
* is normal traffic, not an exception, and until this existed a payments team choosing it for
|
|
62
|
+
* ledger correctness got ~3% of transactions surfacing to the user as "cannot reach the
|
|
63
|
+
* database" with no way to write their own retry, because nothing distinguished `40001` from a
|
|
64
|
+
* dead socket.
|
|
65
|
+
*
|
|
66
|
+
* **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
|
|
67
|
+
* `onRollback` undos fire before each retry, in reverse registration order.
|
|
68
|
+
*
|
|
69
|
+
* Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
|
|
70
|
+
*/
|
|
71
|
+
readonly retry?: number | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
|
|
74
|
+
* provable only by waiting for it is a schedule no test pins — and production passes neither.
|
|
75
|
+
* They are only ever read when `retry` is 1 or more.
|
|
76
|
+
*/
|
|
77
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
78
|
+
readonly random?: Random | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* How long a NESTED scope waits for a sibling scope to finish before it may open, in
|
|
81
|
+
* milliseconds. Sibling scopes under one parent run one after the other, so a body that awaits
|
|
82
|
+
* a sibling started after it waits for itself; past this the waiting call rejects with
|
|
83
|
+
* `X_DB_SIBLING_SCOPE_TIMEOUT` instead of hanging. Default `SIBLING_SCOPE_WAIT_MS` (30 s); `0`
|
|
84
|
+
* waits without a deadline. Read only by a nested scope — the outermost one has no sibling.
|
|
85
|
+
*/
|
|
86
|
+
readonly siblingWaitMs?: number | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
|
|
91
|
+
* value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
|
|
92
|
+
* no parameters, so this is one of the two statements here built as text, and a level spliced into
|
|
93
|
+
* it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
|
|
94
|
+
* value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
|
|
95
|
+
* `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
|
|
96
|
+
* non-string became an uncoded `TypeError` inside a template literal.
|
|
97
|
+
*
|
|
98
|
+
* The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
|
|
99
|
+
* is a type error here rather than a refusal at runtime.
|
|
100
|
+
*/
|
|
101
|
+
const isolationMode = (declared: IsolationLevel): string => {
|
|
102
|
+
switch (declared) {
|
|
103
|
+
case 'read committed':
|
|
104
|
+
return 'ISOLATION LEVEL READ COMMITTED';
|
|
105
|
+
case 'repeatable read':
|
|
106
|
+
return 'ISOLATION LEVEL REPEATABLE READ';
|
|
107
|
+
case 'serializable':
|
|
108
|
+
return 'ISOLATION LEVEL SERIALIZABLE';
|
|
109
|
+
default: {
|
|
110
|
+
const unhandled: never = declared;
|
|
111
|
+
throw isolationLevelInvalid(unhandled);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function beginStatement(options: TransactionOptions): string {
|
|
117
|
+
const modes: string[] = [];
|
|
118
|
+
if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
|
|
119
|
+
if (options.readOnly === true) modes.push('READ ONLY');
|
|
120
|
+
if (options.deferrable === true) modes.push('DEFERRABLE');
|
|
121
|
+
return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
|
|
122
|
+
}
|
package/src/transaction.ts
CHANGED
|
@@ -3,82 +3,17 @@
|
|
|
3
3
|
// the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps
|
|
4
4
|
// to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
import { assert, asyncContext, nanoid } from '@ultimat3/core';
|
|
6
|
+
import { assert, asyncContext, finiteCount, nanoid } from '@ultimat3/core';
|
|
8
7
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
9
|
-
import {
|
|
8
|
+
import { DbError, serializationExhausted } from './errors';
|
|
9
|
+
import { createTurnQueue, type TurnQueue } from './pglite-turns';
|
|
10
10
|
import { markScopeWrote } from './replica-scope';
|
|
11
|
+
import { SIBLING_SCOPE_WAIT_MS, siblingTurn } from './sibling-turn';
|
|
11
12
|
import { raw, type SqlFragment } from './sql';
|
|
12
|
-
import { isRetryableState } from './sqlstate';
|
|
13
|
+
import { isRetryableState, sqlState } from './sqlstate';
|
|
13
14
|
import { serializationRetryDelayMs } from './transaction-backoff';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
readonly id: string;
|
|
17
|
-
/**
|
|
18
|
-
* The client this transaction was **opened on** — `options.client`, or `baseClient()`. Not the
|
|
19
|
-
* reservation the statements run on: what a caller needs to know is which database and which
|
|
20
|
-
* pool this scope belongs to, and the pin is an implementation detail of that.
|
|
21
|
-
*
|
|
22
|
-
* It exists because the answer was unanswerable from above. `@ultimat3/entity`'s repositories
|
|
23
|
-
* can be pinned to a specific client (`database(shard)`), and a pinned repository inside
|
|
24
|
-
* `withTransaction` sends its statements to *its own pool* while the `BEGIN` sits on a
|
|
25
|
-
* connection this scope reserved — so the write commits immediately and survives the rollback,
|
|
26
|
-
* and reads inside the transaction cannot see it. `withTransaction(fn, { client: shard })` does
|
|
27
|
-
* not fix it either: the transaction runs on a *reservation* of the shard and the repository
|
|
28
|
-
* still sends to the pool. With nothing to compare against, tier 2's only honest answer was to
|
|
29
|
-
* refuse (`X_REPO_CLIENT_PINNED`). `tx.origin === thePinnedClient` turns that refusal into the
|
|
30
|
-
* case working — the repository joins its own shard's transaction — and leaves the refusal for
|
|
31
|
-
* what it should always have been: a genuine mix of two databases in one scope.
|
|
32
|
-
*
|
|
33
|
-
* A nested scope reports the root's, because a SAVEPOINT belongs to the transaction that opened.
|
|
34
|
-
*/
|
|
35
|
-
readonly origin: DbClient;
|
|
36
|
-
/** Fired in reverse registration order when this scope rolls back. Never on commit. */
|
|
37
|
-
onRollback(undo: () => void): void;
|
|
38
|
-
/**
|
|
39
|
-
* Fired in registration order once the ROOT transaction has COMMITTED — never on rollback. A
|
|
40
|
-
* nested scope's effects are handed to its parent on `RELEASE` and dropped on `ROLLBACK TO`, so
|
|
41
|
-
* nothing fires for a write that is not durable. What a change feed, a cache purge or a dev row
|
|
42
|
-
* observer needs: reporting a write before COMMIT reports rows a rollback then erases. An effect
|
|
43
|
-
* that throws is swallowed — the transaction already committed, and nothing can un-commit it.
|
|
44
|
-
*/
|
|
45
|
-
onCommit(effect: () => void): void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
|
|
49
|
-
|
|
50
|
-
export interface TransactionOptions {
|
|
51
|
-
readonly isolation?: IsolationLevel | undefined;
|
|
52
|
-
readonly readOnly?: boolean | undefined;
|
|
53
|
-
/** Only meaningful with `serializable` + `readOnly`; lets Postgres wait instead of retrying. */
|
|
54
|
-
readonly deferrable?: boolean | undefined;
|
|
55
|
-
/** Override the ambient pool — tests and `x db branch` run against a specific client. */
|
|
56
|
-
readonly client?: DbClient | undefined;
|
|
57
|
-
/**
|
|
58
|
-
* Extra attempts after a `40001`/`40P01`, and **only** after one. Default 0, so adding the option
|
|
59
|
-
* changed no existing transaction's behaviour (axiom 1) — a retry that ran without being asked
|
|
60
|
-
* for would silently double every non-idempotent handler in the framework.
|
|
61
|
-
*
|
|
62
|
-
* Opt in wherever `isolation: 'serializable'` is set: under SERIALIZABLE a serialization failure
|
|
63
|
-
* is normal traffic, not an exception, and until this existed a payments team choosing it for
|
|
64
|
-
* ledger correctness got ~3% of transactions surfacing to the user as "cannot reach the
|
|
65
|
-
* database" with no way to write their own retry, because nothing distinguished `40001` from a
|
|
66
|
-
* dead socket.
|
|
67
|
-
*
|
|
68
|
-
* **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
|
|
69
|
-
* `onRollback` undos fire before each retry, in reverse registration order.
|
|
70
|
-
*
|
|
71
|
-
* Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
|
|
72
|
-
*/
|
|
73
|
-
readonly retry?: number | undefined;
|
|
74
|
-
/**
|
|
75
|
-
* The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
|
|
76
|
-
* provable only by waiting for it is a schedule no test pins — and production passes neither.
|
|
77
|
-
* They are only ever read when `retry` is 1 or more.
|
|
78
|
-
*/
|
|
79
|
-
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
80
|
-
readonly random?: Random | undefined;
|
|
81
|
-
}
|
|
15
|
+
import { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
|
|
16
|
+
import { beginStatement, type DbTx, type TransactionOptions } from './transaction-options';
|
|
82
17
|
|
|
83
18
|
interface TxState {
|
|
84
19
|
readonly tx: DbTx;
|
|
@@ -100,6 +35,37 @@ interface TxState {
|
|
|
100
35
|
* transaction believes a dead one is live.
|
|
101
36
|
*/
|
|
102
37
|
readonly live: { value: boolean };
|
|
38
|
+
/** Whether the SERVER has aborted the transaction — shared by reference, like `live`. */
|
|
39
|
+
readonly abort: TxAbort;
|
|
40
|
+
/**
|
|
41
|
+
* One CHILD scope at a time, per scope. Savepoints are a stack on the server: two siblings opened
|
|
42
|
+
* under `Promise.all` interleaved `SAVEPOINT x_sp_1, SAVEPOINT x_sp_2`, and `RELEASE x_sp_1`
|
|
43
|
+
* destroyed `x_sp_2` with it — the second scope's work released into the first's, its own
|
|
44
|
+
* `RELEASE` answered `3B001`, and a `ROLLBACK TO x_sp_1` undid a sibling that had reported
|
|
45
|
+
* success. Each scope owns its own queue, so a child of the scope holding the turn never waits
|
|
46
|
+
* behind its parent, and the open savepoints are always one chain.
|
|
47
|
+
*/
|
|
48
|
+
readonly children: TurnQueue;
|
|
49
|
+
/** The savepoint of the child holding that turn — what a sibling that gave up waiting names. */
|
|
50
|
+
readonly holder: { value: string | undefined };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The first statement the server refused inside the transaction, kept until a `ROLLBACK TO
|
|
55
|
+
* SAVEPOINT` undoes it. Postgres aborts the whole transaction on ANY statement error; every later
|
|
56
|
+
* statement answers `25P02`, and `COMMIT` answers `ROLLBACK` with no error at all.
|
|
57
|
+
*/
|
|
58
|
+
type TxAbort = { value: boolean; first: unknown };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Only a failure that carries a SQLSTATE: that is the server refusing a statement it READ, which is
|
|
62
|
+
* what aborts. A refusal raised before the send (a ragged array parameter) left the transaction
|
|
63
|
+
* untouched, and a dead socket is reported by the COMMIT that follows it.
|
|
64
|
+
*/
|
|
65
|
+
function noteFailure(abort: TxAbort, error: unknown): void {
|
|
66
|
+
if (abort.value || sqlState(error) === undefined) return;
|
|
67
|
+
abort.value = true;
|
|
68
|
+
abort.first = error;
|
|
103
69
|
}
|
|
104
70
|
|
|
105
71
|
// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
|
|
@@ -131,47 +97,22 @@ export function liveTxConnection(): DbClient | undefined {
|
|
|
131
97
|
return state?.live.value === true ? state.connection : undefined;
|
|
132
98
|
}
|
|
133
99
|
|
|
134
|
-
/**
|
|
135
|
-
* The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
|
|
136
|
-
* value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
|
|
137
|
-
* no parameters, so this is one of the two statements here built as text, and a level spliced into
|
|
138
|
-
* it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
|
|
139
|
-
* value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
|
|
140
|
-
* `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
|
|
141
|
-
* non-string became an uncoded `TypeError` inside a template literal.
|
|
142
|
-
*
|
|
143
|
-
* The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
|
|
144
|
-
* is a type error here rather than a refusal at runtime.
|
|
145
|
-
*/
|
|
146
|
-
const isolationMode = (declared: IsolationLevel): string => {
|
|
147
|
-
switch (declared) {
|
|
148
|
-
case 'read committed':
|
|
149
|
-
return 'ISOLATION LEVEL READ COMMITTED';
|
|
150
|
-
case 'repeatable read':
|
|
151
|
-
return 'ISOLATION LEVEL REPEATABLE READ';
|
|
152
|
-
case 'serializable':
|
|
153
|
-
return 'ISOLATION LEVEL SERIALIZABLE';
|
|
154
|
-
default: {
|
|
155
|
-
const unhandled: never = declared;
|
|
156
|
-
throw isolationLevelInvalid(unhandled);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
export function beginStatement(options: TransactionOptions): string {
|
|
162
|
-
const modes: string[] = [];
|
|
163
|
-
if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
|
|
164
|
-
if (options.readOnly === true) modes.push('READ ONLY');
|
|
165
|
-
if (options.deferrable === true) modes.push('DEFERRABLE');
|
|
166
|
-
return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
100
|
/**
|
|
170
101
|
* How the ROOT transaction ended, shared by every nested scope. An effect registered by a straggler
|
|
171
102
|
* — a promise chain `fn` forgot to await, still inside the store after the scope closed — runs at
|
|
172
103
|
* once after a COMMIT and is dropped after a ROLLBACK, rather than waiting on a list nobody reads.
|
|
173
104
|
*/
|
|
174
|
-
type TxOutcome = { value: 'open' | 'committed' | 'rolled-back' };
|
|
105
|
+
type TxOutcome = { value: 'open' | 'committed' | 'rolled-back' | 'unknown' };
|
|
106
|
+
|
|
107
|
+
/** One statement on the scope's connection, its refusal remembered before the caller can drop it. */
|
|
108
|
+
async function watched<T>(abort: TxAbort, sent: Promise<T>): Promise<T> {
|
|
109
|
+
try {
|
|
110
|
+
return await sent;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
noteFailure(abort, error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
175
116
|
|
|
176
117
|
function makeTx(
|
|
177
118
|
id: string,
|
|
@@ -180,13 +121,14 @@ function makeTx(
|
|
|
180
121
|
commits: (() => void)[],
|
|
181
122
|
origin: DbClient,
|
|
182
123
|
outcome: TxOutcome,
|
|
124
|
+
abort: TxAbort,
|
|
183
125
|
): DbTx {
|
|
184
126
|
return {
|
|
185
127
|
id,
|
|
186
128
|
origin,
|
|
187
|
-
query: <T>(fragment: SqlFragment) => connection.query<T>(fragment),
|
|
188
|
-
one: <T>(fragment: SqlFragment) => connection.one<T>(fragment),
|
|
189
|
-
execute: (fragment: SqlFragment) => connection.execute(fragment),
|
|
129
|
+
query: <T>(fragment: SqlFragment) => watched(abort, connection.query<T>(fragment)),
|
|
130
|
+
one: <T>(fragment: SqlFragment) => watched(abort, connection.one<T>(fragment)),
|
|
131
|
+
execute: (fragment: SqlFragment) => watched(abort, connection.execute(fragment)),
|
|
190
132
|
onRollback: (undo: () => void) => {
|
|
191
133
|
undos.push(undo);
|
|
192
134
|
},
|
|
@@ -197,6 +139,9 @@ function makeTx(
|
|
|
197
139
|
};
|
|
198
140
|
}
|
|
199
141
|
|
|
142
|
+
const isAborted = (error: unknown): boolean =>
|
|
143
|
+
error instanceof DbError && error.code === 'X_DB_TRANSACTION_ABORTED';
|
|
144
|
+
|
|
200
145
|
/** Commit effects are best-effort too: the transaction is durable, and one throwing must not undo that. */
|
|
201
146
|
function runCommits(commits: readonly (() => void)[]): void {
|
|
202
147
|
for (const effect of commits) {
|
|
@@ -219,9 +164,24 @@ function runUndos(undos: readonly (() => void)[]): void {
|
|
|
219
164
|
}
|
|
220
165
|
}
|
|
221
166
|
|
|
222
|
-
async function runNested<T>(
|
|
167
|
+
async function runNested<T>(
|
|
168
|
+
outer: TxState,
|
|
169
|
+
fn: (tx: DbTx) => Promise<T>,
|
|
170
|
+
waitMs: number,
|
|
171
|
+
): Promise<T> {
|
|
172
|
+
// Held to the end of this function, on every exit: the next sibling's SAVEPOINT is sent only
|
|
173
|
+
// after this scope's RELEASE or ROLLBACK TO has been answered. Under a deadline, because a body
|
|
174
|
+
// awaiting a sibling queued behind it is a cycle (`sibling-turn.ts`).
|
|
175
|
+
using _turn = await siblingTurn(outer.children, waitMs, () =>
|
|
176
|
+
siblingScopeTimeout(outer.tx.id, outer.holder.value, waitMs),
|
|
177
|
+
);
|
|
178
|
+
const { abort } = outer;
|
|
179
|
+
// Named, rather than left to the SAVEPOINT below to fail with `25P02`: the statement that broke
|
|
180
|
+
// the transaction is the one the caller caught, and it is the only one worth reading.
|
|
181
|
+
if (abort.value) throw transactionAborted(abort.first);
|
|
223
182
|
outer.savepoints.value += 1;
|
|
224
183
|
const name = `x_sp_${outer.savepoints.value}`;
|
|
184
|
+
outer.holder.value = name;
|
|
225
185
|
const undos: (() => void)[] = [];
|
|
226
186
|
const commits: (() => void)[] = [];
|
|
227
187
|
const tx = makeTx(
|
|
@@ -231,15 +191,21 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
|
|
|
231
191
|
commits,
|
|
232
192
|
outer.tx.origin,
|
|
233
193
|
outer.outcome,
|
|
194
|
+
abort,
|
|
234
195
|
);
|
|
235
196
|
// `SAVEPOINT` and `RELEASE` are deliberately uncaught: a savepoint that was never taken means
|
|
236
197
|
// this scope never opened, and a release that failed means its work is not durable in the outer
|
|
237
198
|
// one. Both are the caller's failure to see — swallowing either would run the rest of the unit
|
|
238
199
|
// of work against a transaction that is not the one it thinks it is in.
|
|
239
|
-
await outer.connection.execute(raw(`SAVEPOINT ${name}`));
|
|
200
|
+
await watched(abort, outer.connection.execute(raw(`SAVEPOINT ${name}`)));
|
|
240
201
|
try {
|
|
241
|
-
const
|
|
242
|
-
|
|
202
|
+
const children = createTurnQueue();
|
|
203
|
+
const scope: TxState = { ...outer, tx, undos, commits, children, holder: { value: undefined } };
|
|
204
|
+
const result = await storage.run(scope, () => fn(tx));
|
|
205
|
+
// The body swallowed a failed statement. RELEASE would answer `25P02`; the scope is rolled
|
|
206
|
+
// back below instead, which is the one thing that makes the OUTER transaction usable again.
|
|
207
|
+
if (abort.value) throw transactionAborted(abort.first);
|
|
208
|
+
await watched(abort, outer.connection.execute(raw(`RELEASE SAVEPOINT ${name}`)));
|
|
243
209
|
// The nested scope committed into an outer one that can still roll back, so its undos
|
|
244
210
|
// must survive: hand them to the parent rather than dropping them. Its commit effects wait
|
|
245
211
|
// for the ROOT's COMMIT the same way — a released savepoint is not yet durable.
|
|
@@ -247,10 +213,20 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
|
|
|
247
213
|
outer.commits.push(...commits);
|
|
248
214
|
return result;
|
|
249
215
|
} catch (error) {
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
216
|
+
// The caller still needs the error that caused the rollback, never the rollback's own — but a
|
|
217
|
+
// `ROLLBACK TO` that failed is no longer forgotten. The scope's work was NOT undone, so the
|
|
218
|
+
// root is marked aborted and its COMMIT refuses: committing would store the writes of a scope
|
|
219
|
+
// that just told its caller they were rolled back.
|
|
220
|
+
try {
|
|
221
|
+
await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
|
|
222
|
+
// Whatever broke the transaction happened after this savepoint — the SAVEPOINT itself was
|
|
223
|
+
// accepted — so the server has undone it and statements are accepted again.
|
|
224
|
+
abort.value = false;
|
|
225
|
+
abort.first = undefined;
|
|
226
|
+
} catch (rollbackError) {
|
|
227
|
+
if (!abort.value) abort.first = rollbackError;
|
|
228
|
+
abort.value = true;
|
|
229
|
+
}
|
|
254
230
|
runUndos(undos);
|
|
255
231
|
throw error;
|
|
256
232
|
}
|
|
@@ -281,7 +257,8 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
281
257
|
const undos: (() => void)[] = [];
|
|
282
258
|
const commits: (() => void)[] = [];
|
|
283
259
|
const outcome: TxOutcome = { value: 'open' };
|
|
284
|
-
const
|
|
260
|
+
const abort: TxAbort = { value: false, first: undefined };
|
|
261
|
+
const tx = makeTx(`tx_${nanoid(12)}`, connection, undos, commits, client, outcome, abort);
|
|
285
262
|
// Each attempt gets its own state, and therefore its own `live` — a retry re-runs `fn` against a
|
|
286
263
|
// transaction that is genuinely new, so the abandoned attempt's stragglers must read as closed.
|
|
287
264
|
const state: TxState = {
|
|
@@ -292,12 +269,20 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
292
269
|
outcome,
|
|
293
270
|
savepoints: { value: 0 },
|
|
294
271
|
live: { value: true },
|
|
272
|
+
abort,
|
|
273
|
+
children: createTurnQueue(),
|
|
274
|
+
holder: { value: undefined },
|
|
295
275
|
};
|
|
296
276
|
|
|
297
277
|
let committed = false;
|
|
278
|
+
let commitSent = false;
|
|
298
279
|
try {
|
|
299
280
|
await connection.execute(raw(beginStatement(options)));
|
|
300
281
|
const result = await storage.run(state, () => fn(tx));
|
|
282
|
+
// Refused before the COMMIT is sent: the server would answer it `ROLLBACK` with no error. The
|
|
283
|
+
// funnels read that tag too (`commit-tag.ts`), for an abort this scope's handle never saw.
|
|
284
|
+
if (abort.value) throw transactionAborted(abort.first);
|
|
285
|
+
commitSent = true;
|
|
301
286
|
await connection.execute(raw('COMMIT'));
|
|
302
287
|
// After COMMIT answered, and outside the `catch` below: a failing effect must never be read as
|
|
303
288
|
// a failed transaction and trigger a ROLLBACK of work the server already made durable.
|
|
@@ -310,6 +295,12 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
310
295
|
// Best-effort: the caller needs the original failure, never the rollback's. A BEGIN that
|
|
311
296
|
// itself failed opened nothing, so this ROLLBACK is a no-op the server answers with a notice.
|
|
312
297
|
await connection.execute(raw('ROLLBACK')).catch(() => undefined);
|
|
298
|
+
// A COMMIT rejected with no SQLSTATE and no ROLLBACK tag never got its answer, so the unit of
|
|
299
|
+
// work may be durable. Neither list runs: see `commitUnknown`.
|
|
300
|
+
if (commitSent && sqlState(error) === undefined && !isAborted(error)) {
|
|
301
|
+
outcome.value = 'unknown';
|
|
302
|
+
throw commitUnknown(error);
|
|
303
|
+
}
|
|
313
304
|
outcome.value = 'rolled-back';
|
|
314
305
|
runUndos(undos);
|
|
315
306
|
throw error;
|
|
@@ -336,6 +327,11 @@ export async function withTransaction<T>(
|
|
|
336
327
|
`withTransaction({ retry }) needs a whole number of extra attempts, 0 or more; a budget that is not one opens nothing and runs fn zero times`,
|
|
337
328
|
"pass an integer — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and parse it before you pass it: Number(process.env.DB_RETRY) is NaN when the variable is unset",
|
|
338
329
|
);
|
|
330
|
+
const siblingWaitMs = finiteCount(
|
|
331
|
+
'withTransaction',
|
|
332
|
+
'siblingWaitMs',
|
|
333
|
+
options.siblingWaitMs ?? SIBLING_SCOPE_WAIT_MS,
|
|
334
|
+
);
|
|
339
335
|
const outer = storage.get();
|
|
340
336
|
if (outer !== undefined) {
|
|
341
337
|
// A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for:
|
|
@@ -350,7 +346,21 @@ export async function withTransaction<T>(
|
|
|
350
346
|
'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',
|
|
351
347
|
"move the retry to the OUTERMOST withTransaction — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and drop it here",
|
|
352
348
|
);
|
|
353
|
-
|
|
349
|
+
// The same argument for everything else a SAVEPOINT cannot honour. The isolation level and the
|
|
350
|
+
// access mode were fixed by the root's BEGIN, and a savepoint lives on the root's connection:
|
|
351
|
+
// `{ client: shard }` in here recorded a savepoint on the OUTER database and nothing on the
|
|
352
|
+
// shard, and `{ readOnly: true }` wrapped writes that then committed.
|
|
353
|
+
assert(
|
|
354
|
+
options.isolation === undefined && options.readOnly !== true && options.deferrable !== true,
|
|
355
|
+
'withTransaction({ isolation, readOnly, deferrable }) inside another transaction: a nested scope is a SAVEPOINT in the transaction the outermost BEGIN opened, and its isolation level and access mode cannot change after that',
|
|
356
|
+
"state them on the OUTERMOST withTransaction — withTransaction(fn, { isolation: 'serializable', readOnly: true }) — and drop them here",
|
|
357
|
+
);
|
|
358
|
+
assert(
|
|
359
|
+
options.client === undefined || options.client === outer.tx.origin,
|
|
360
|
+
'withTransaction({ client }) inside a transaction opened on a different client: a nested scope is a SAVEPOINT on the outer connection, so nothing would run on the client named here',
|
|
361
|
+
'open the second database in its own unit of work, outside this one — await withTransaction(fn, { client }) after the outer scope returns — or drop { client } to join the outer transaction',
|
|
362
|
+
);
|
|
363
|
+
return runNested(outer, fn, siblingWaitMs);
|
|
354
364
|
}
|
|
355
365
|
|
|
356
366
|
const attempts = (options.retry ?? 0) + 1;
|