@ultimat3/db 29.4.0 → 30.1.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 +74 -110
- package/README.md +163 -15
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +20 -0
- package/src/concurrent-plan.ts +135 -17
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +17 -3
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +67 -0
- package/src/errors.ts +77 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +6 -1
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +105 -79
- package/src/index.ts +129 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +12 -4
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +45 -4
- package/src/migration-errors.ts +27 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +50 -22
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
|
@@ -49,6 +49,20 @@ export const siblingScopeTimeout = (
|
|
|
49
49
|
meta: { parent, waitedMs, ...(holder === undefined ? {} : { holder }) },
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A statement on a handle whose scope is over: an un-awaited promise or the loser of a `Promise.all`
|
|
54
|
+
* outlived `withTransaction`. It used to fall through the pin's `held` fence to the pool and
|
|
55
|
+
* autocommit, a row for a unit of work that had rolled back. Terminal: the same call sent again
|
|
56
|
+
* reaches the same dead handle.
|
|
57
|
+
*/
|
|
58
|
+
export const txClosed = (id: string, outcome: string): DbError =>
|
|
59
|
+
new DbError({
|
|
60
|
+
code: 'X_DB_TRANSACTION_CLOSED',
|
|
61
|
+
cause: `a statement was sent on transaction ${id} after its scope ended (${outcome}): an un-awaited promise, or the loser of a Promise.all, outlived withTransaction, and it was never part of that unit of work`,
|
|
62
|
+
fix: 'await outsideTransaction(fn) # for work that must outlive the scope; anything else: await it inside withTransaction(fn)',
|
|
63
|
+
meta: { tx: id, outcome },
|
|
64
|
+
});
|
|
65
|
+
|
|
52
66
|
/**
|
|
53
67
|
* `COMMIT` was sent and rejected with no SQLSTATE — the socket went before the answer did. The
|
|
54
68
|
* transaction is durable or it is not, and nothing on this side can say which, so neither list
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: the two refusals a statement meets before it reaches a transaction's
|
|
2
|
+
// connection, a scope that is over and work whose caller was already answered, and the scope store
|
|
3
|
+
// the second reads. Never on `runOn`: `settleFailure` and a job step's fenced write send past the
|
|
4
|
+
// abort through that funnel on purpose.
|
|
5
|
+
|
|
6
|
+
import { abandonment, asyncContext } from '@ultimat3/core';
|
|
7
|
+
// Type-only: `transaction.ts` imports this module, and a runtime edge would close the cycle.
|
|
8
|
+
import type { TxState } from './transaction';
|
|
9
|
+
import { txClosed } from './transaction-errors';
|
|
10
|
+
|
|
11
|
+
/** `completeTransaction()`'s mark on a root. */
|
|
12
|
+
export type TxMark = { readonly value: boolean };
|
|
13
|
+
|
|
14
|
+
// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
|
|
15
|
+
// browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of
|
|
16
|
+
// `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays
|
|
17
|
+
// nothing for the deferral.
|
|
18
|
+
export const storage = asyncContext<TxState>('a database transaction');
|
|
19
|
+
|
|
20
|
+
/** A `DbTx` statement after its scope ended, committed and rolled back alike: one rule. */
|
|
21
|
+
export function refuseIfFinished(id: string, outcome: string): void {
|
|
22
|
+
if (outcome !== 'open') throw txClosed(id, outcome);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The mark of the root still OPEN on this async context; a finished one exempts nothing. */
|
|
26
|
+
function openRootMark(): TxMark | undefined {
|
|
27
|
+
const state = storage.get();
|
|
28
|
+
return state?.live.value === true ? state.completing : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Throws the signal's own reason, the same object: no wrapper and no new code. A marked root is
|
|
33
|
+
* owed its COMMIT, so it passes. `completing` is the scope's own for a `DbTx` statement; omitted, a
|
|
34
|
+
* write with no handle (entity's `writing()`, the enqueue facade) answers to the ambient root.
|
|
35
|
+
*/
|
|
36
|
+
export function refuseAbandoned(completing: TxMark | undefined = openRootMark()): void {
|
|
37
|
+
if (completing?.value === true) return;
|
|
38
|
+
const reason = abandonment();
|
|
39
|
+
if (reason !== undefined) throw reason;
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: the settings every root transaction begins with — the seam row security
|
|
2
|
+
// rides. `@ultimat3/entity` registers the provider (it alone knows the actor's tenant and whether a
|
|
3
|
+
// `crossTenant()` scope is open); `withTransaction` asks it once per BEGIN. Same shape as core's
|
|
4
|
+
// `currentWriteOrigin`: a value read at the moment of use, never threaded through a parameter.
|
|
5
|
+
|
|
6
|
+
import type { LocalSettings } from './local-settings';
|
|
7
|
+
|
|
8
|
+
type SettingsProvider = () => LocalSettings | undefined;
|
|
9
|
+
|
|
10
|
+
let provider: SettingsProvider | undefined;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Install the provider, or `undefined` to remove it. One provider, not a list: two writers of the
|
|
14
|
+
* same setting is a disagreement nobody could see. Unset — every app without row security — costs
|
|
15
|
+
* each BEGIN one comparison and no statement.
|
|
16
|
+
*/
|
|
17
|
+
export function setTransactionSettings(next: SettingsProvider | undefined): void {
|
|
18
|
+
provider = next;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the transaction opening now must set first, or `undefined` for nothing. */
|
|
22
|
+
export function transactionSettings(): LocalSettings | undefined {
|
|
23
|
+
return provider?.();
|
|
24
|
+
}
|
package/src/transaction.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
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 { assert,
|
|
6
|
+
import { assert, finiteCount, nanoid } from '@ultimat3/core';
|
|
7
7
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
8
8
|
import { DbError, serializationExhausted } from './errors';
|
|
9
|
+
import { setLocal } from './local-settings';
|
|
9
10
|
import { createTurnQueue, type TurnQueue } from './pglite-turns';
|
|
10
11
|
import { markScopeWrote } from './replica-scope';
|
|
11
12
|
import { SIBLING_SCOPE_WAIT_MS, siblingTurn } from './sibling-turn';
|
|
@@ -13,9 +14,11 @@ import { raw, type SqlFragment } from './sql';
|
|
|
13
14
|
import { isRetryableState, sqlState } from './sqlstate';
|
|
14
15
|
import { serializationRetryDelayMs } from './transaction-backoff';
|
|
15
16
|
import { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
|
|
17
|
+
import { refuseAbandoned, refuseIfFinished, storage, type TxMark } from './transaction-guard';
|
|
16
18
|
import { beginStatement, type DbTx, type TransactionOptions } from './transaction-options';
|
|
19
|
+
import { transactionSettings } from './transaction-settings';
|
|
17
20
|
|
|
18
|
-
interface TxState {
|
|
21
|
+
export interface TxState {
|
|
19
22
|
readonly tx: DbTx;
|
|
20
23
|
readonly connection: DbClient;
|
|
21
24
|
readonly undos: (() => void)[];
|
|
@@ -37,6 +40,12 @@ interface TxState {
|
|
|
37
40
|
readonly live: { value: boolean };
|
|
38
41
|
/** Whether the SERVER has aborted the transaction — shared by reference, like `live`. */
|
|
39
42
|
readonly abort: TxAbort;
|
|
43
|
+
/**
|
|
44
|
+
* The root must reach its COMMIT even if this scope is abandoned: `completeTransaction()` set it,
|
|
45
|
+
* so statements and COMMIT skip the abandonment check. Shared by reference across nesting like
|
|
46
|
+
* `live`, and new per attempt: a retry starts unmarked.
|
|
47
|
+
*/
|
|
48
|
+
readonly completing: { value: boolean };
|
|
40
49
|
/**
|
|
41
50
|
* One CHILD scope at a time, per scope. Savepoints are a stack on the server: two siblings opened
|
|
42
51
|
* under `Promise.all` interleaved `SAVEPOINT x_sp_1, SAVEPOINT x_sp_2`, and `RELEASE x_sp_1`
|
|
@@ -68,12 +77,6 @@ function noteFailure(abort: TxAbort, error: unknown): void {
|
|
|
68
77
|
abort.first = error;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
|
|
72
|
-
// browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of
|
|
73
|
-
// `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays
|
|
74
|
-
// nothing for the deferral.
|
|
75
|
-
const storage = asyncContext<TxState>('a database transaction');
|
|
76
|
-
|
|
77
80
|
/** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */
|
|
78
81
|
export function currentTx(): DbTx | undefined {
|
|
79
82
|
return storage.get()?.tx;
|
|
@@ -96,8 +99,8 @@ export function outsideTransaction<R>(fn: () => R): R {
|
|
|
96
99
|
* store survives the scope. The one reader is `pglite.ts`'s `run()`, where the answer decides
|
|
97
100
|
* whether a statement may skip the single session's turn queue; skipping it on a *closed*
|
|
98
101
|
* transaction is how a straggler landed inside whichever unit of work held the connection next.
|
|
99
|
-
* `currentTx()` deliberately still answers with the dead handle: its statements
|
|
100
|
-
*
|
|
102
|
+
* `currentTx()` deliberately still answers with the dead handle: its statements refuse
|
|
103
|
+
* (`X_DB_TRANSACTION_CLOSED`, `makeTx`), so nothing a straggler sends reaches any connection.
|
|
101
104
|
*
|
|
102
105
|
* The CONNECTION, never a boolean: "is any transaction open" let an autocommit statement on
|
|
103
106
|
* client A, issued inside a transaction on client B, skip A's queue and run inside A's own open
|
|
@@ -113,7 +116,7 @@ export function liveTxConnection(): DbClient | undefined {
|
|
|
113
116
|
* — a promise chain `fn` forgot to await, still inside the store after the scope closed — runs at
|
|
114
117
|
* once after a COMMIT and is dropped after a ROLLBACK, rather than waiting on a list nobody reads.
|
|
115
118
|
*/
|
|
116
|
-
type TxOutcome = { value: 'open' | 'committed' | 'rolled-back' | 'unknown' };
|
|
119
|
+
type TxOutcome = { value: 'open' | 'ending' | 'committed' | 'rolled-back' | 'unknown' };
|
|
117
120
|
|
|
118
121
|
/** One statement on the scope's connection, its refusal remembered before the caller can drop it. */
|
|
119
122
|
async function watched<T>(abort: TxAbort, sent: Promise<T>): Promise<T> {
|
|
@@ -133,19 +136,29 @@ function makeTx(
|
|
|
133
136
|
origin: DbClient,
|
|
134
137
|
outcome: TxOutcome,
|
|
135
138
|
abort: TxAbort,
|
|
139
|
+
completing: TxMark,
|
|
136
140
|
): DbTx {
|
|
141
|
+
// `async` so a refusal rejects like the statement would have, and it sends synchronously, in
|
|
142
|
+
// call order. Never on `outer.connection` or the `ROLLBACK`s: those are how the scope ends.
|
|
143
|
+
const guarded = async <T>(send: () => Promise<T>): Promise<T> => {
|
|
144
|
+
refuseIfFinished(id, outcome.value);
|
|
145
|
+
refuseAbandoned(completing);
|
|
146
|
+
return watched(abort, send());
|
|
147
|
+
};
|
|
137
148
|
return {
|
|
138
149
|
id,
|
|
139
150
|
origin,
|
|
140
|
-
query: <T>(fragment: SqlFragment) =>
|
|
141
|
-
one: <T>(fragment: SqlFragment) =>
|
|
142
|
-
execute: (fragment: SqlFragment) =>
|
|
151
|
+
query: <T>(fragment: SqlFragment) => guarded(() => connection.query<T>(fragment)),
|
|
152
|
+
one: <T>(fragment: SqlFragment) => guarded(() => connection.one<T>(fragment)),
|
|
153
|
+
execute: (fragment: SqlFragment) => guarded(() => connection.execute(fragment)),
|
|
143
154
|
onRollback: (undo: () => void) => {
|
|
144
155
|
undos.push(undo);
|
|
145
156
|
},
|
|
146
157
|
onCommit: (effect: () => void) => {
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
// Off the straggler's dead store: the effect is after the COMMIT, so `db()` in it is the pool.
|
|
159
|
+
if (outcome.value === 'committed') storage.exit(() => runCommits([effect]));
|
|
160
|
+
// 'ending' too: an in-flight COMMIT runs this list once answered, a ROLLBACK never does.
|
|
161
|
+
else if (outcome.value === 'open' || outcome.value === 'ending') commits.push(effect);
|
|
149
162
|
},
|
|
150
163
|
};
|
|
151
164
|
}
|
|
@@ -180,12 +193,16 @@ async function runNested<T>(
|
|
|
180
193
|
fn: (tx: DbTx) => Promise<T>,
|
|
181
194
|
waitMs: number,
|
|
182
195
|
): Promise<T> {
|
|
196
|
+
refuseIfFinished(outer.tx.id, outer.outcome.value);
|
|
197
|
+
refuseAbandoned(outer.completing);
|
|
183
198
|
// Held to the end of this function, on every exit: the next sibling's SAVEPOINT is sent only
|
|
184
199
|
// after this scope's RELEASE or ROLLBACK TO has been answered. Under a deadline, because a body
|
|
185
200
|
// awaiting a sibling queued behind it is a cycle (`sibling-turn.ts`).
|
|
186
201
|
using _turn = await siblingTurn(outer.children, waitMs, () =>
|
|
187
202
|
siblingScopeTimeout(outer.tx.id, outer.holder.value, waitMs),
|
|
188
203
|
);
|
|
204
|
+
// Again: the wait is how a sibling outlives the root, and the pin below is no longer this scope's.
|
|
205
|
+
refuseIfFinished(outer.tx.id, outer.outcome.value);
|
|
189
206
|
const { abort } = outer;
|
|
190
207
|
// Named, rather than left to the SAVEPOINT below to fail with `25P02`: the statement that broke
|
|
191
208
|
// the transaction is the one the caller caught, and it is the only one worth reading.
|
|
@@ -203,6 +220,7 @@ async function runNested<T>(
|
|
|
203
220
|
outer.tx.origin,
|
|
204
221
|
outer.outcome,
|
|
205
222
|
abort,
|
|
223
|
+
outer.completing,
|
|
206
224
|
);
|
|
207
225
|
// `SAVEPOINT` and `RELEASE` are deliberately uncaught: a savepoint that was never taken means
|
|
208
226
|
// this scope never opened, and a release that failed means its work is not durable in the outer
|
|
@@ -213,6 +231,7 @@ async function runNested<T>(
|
|
|
213
231
|
const children = createTurnQueue();
|
|
214
232
|
const scope: TxState = { ...outer, tx, undos, commits, children, holder: { value: undefined } };
|
|
215
233
|
const result = await storage.run(scope, () => fn(tx));
|
|
234
|
+
refuseIfFinished(outer.tx.id, outer.outcome.value);
|
|
216
235
|
// The body swallowed a failed statement. RELEASE would answer `25P02`; the scope is rolled
|
|
217
236
|
// back below instead, which is the one thing that makes the OUTER transaction usable again.
|
|
218
237
|
if (abort.value) throw transactionAborted(abort.first);
|
|
@@ -227,9 +246,12 @@ async function runNested<T>(
|
|
|
227
246
|
// The caller still needs the error that caused the rollback, never the rollback's own — but a
|
|
228
247
|
// `ROLLBACK TO` that failed is no longer forgotten. The scope's work was NOT undone, so the
|
|
229
248
|
// root is marked aborted and its COMMIT refuses: committing would store the writes of a scope
|
|
230
|
-
// that just told its caller they were rolled back.
|
|
249
|
+
// that just told its caller they were rolled back. Sent only into an open root: the pin of a
|
|
250
|
+
// finished one is back in the pool, and the root's own end already undid this savepoint.
|
|
231
251
|
try {
|
|
232
|
-
|
|
252
|
+
if (outer.outcome.value === 'open') {
|
|
253
|
+
await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
|
|
254
|
+
}
|
|
233
255
|
// Whatever broke the transaction happened after this savepoint — the SAVEPOINT itself was
|
|
234
256
|
// accepted — so the server has undone it and statements are accepted again.
|
|
235
257
|
abort.value = false;
|
|
@@ -249,6 +271,8 @@ async function runNested<T>(
|
|
|
249
271
|
* connection whose transaction is already gone.
|
|
250
272
|
*/
|
|
251
273
|
async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOptions): Promise<T> {
|
|
274
|
+
// Before the pin, and before `markScopeWrote`: a scope already abandoned opens nothing.
|
|
275
|
+
refuseAbandoned();
|
|
252
276
|
const client = options.client ?? baseClient();
|
|
253
277
|
// A transaction is assumed to write unless it said otherwise, so every read AFTER it in the same
|
|
254
278
|
// `withReplicaReads` scope is the primary's. The pin below already keeps the transaction's own
|
|
@@ -269,7 +293,17 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
269
293
|
const commits: (() => void)[] = [];
|
|
270
294
|
const outcome: TxOutcome = { value: 'open' };
|
|
271
295
|
const abort: TxAbort = { value: false, first: undefined };
|
|
272
|
-
const
|
|
296
|
+
const completing = { value: false };
|
|
297
|
+
const tx = makeTx(
|
|
298
|
+
`tx_${nanoid(12)}`,
|
|
299
|
+
connection,
|
|
300
|
+
undos,
|
|
301
|
+
commits,
|
|
302
|
+
client,
|
|
303
|
+
outcome,
|
|
304
|
+
abort,
|
|
305
|
+
completing,
|
|
306
|
+
);
|
|
273
307
|
// Each attempt gets its own state, and therefore its own `live` — a retry re-runs `fn` against a
|
|
274
308
|
// transaction that is genuinely new, so the abandoned attempt's stragglers must read as closed.
|
|
275
309
|
const state: TxState = {
|
|
@@ -281,6 +315,7 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
281
315
|
savepoints: { value: 0 },
|
|
282
316
|
live: { value: true },
|
|
283
317
|
abort,
|
|
318
|
+
completing,
|
|
284
319
|
children: createTurnQueue(),
|
|
285
320
|
holder: { value: undefined },
|
|
286
321
|
};
|
|
@@ -289,11 +324,20 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
289
324
|
let commitSent = false;
|
|
290
325
|
try {
|
|
291
326
|
await connection.execute(raw(beginStatement(options)));
|
|
327
|
+
// Row security's tenant, local to this transaction: inside the BEGIN, so it ends with it.
|
|
328
|
+
const settings = transactionSettings();
|
|
329
|
+
if (settings !== undefined) await setLocal(connection, settings);
|
|
292
330
|
const result = await storage.run(state, () => fn(tx));
|
|
293
331
|
// Refused before the COMMIT is sent: the server would answer it `ROLLBACK` with no error. The
|
|
294
332
|
// funnels read that tag too (`commit-tag.ts`), for an abort this scope's handle never saw.
|
|
295
333
|
if (abort.value) throw transactionAborted(abort.first);
|
|
334
|
+
// Abandoned between the last statement and here: the `catch` rolls back. A marked root's COMMIT
|
|
335
|
+
// is what makes its keyed replay answer success, so it goes through.
|
|
336
|
+
refuseAbandoned(completing);
|
|
296
337
|
commitSent = true;
|
|
338
|
+
// Before the COMMIT is on the wire: a straggler sent now would run after it on this connection,
|
|
339
|
+
// outside any transaction. The ROLLBACK's window below is the same hole.
|
|
340
|
+
outcome.value = 'ending';
|
|
297
341
|
await connection.execute(raw('COMMIT'));
|
|
298
342
|
// After COMMIT answered, and outside the `catch` below: a failing effect must never be read as
|
|
299
343
|
// a failed transaction and trigger a ROLLBACK of work the server already made durable.
|
|
@@ -303,6 +347,7 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
303
347
|
return result;
|
|
304
348
|
} catch (error) {
|
|
305
349
|
if (committed) throw error;
|
|
350
|
+
outcome.value = 'ending';
|
|
306
351
|
// Best-effort: the caller needs the original failure, never the rollback's. A BEGIN that
|
|
307
352
|
// itself failed opened nothing, so this ROLLBACK is a no-op the server answers with a notice.
|
|
308
353
|
await connection.execute(raw('ROLLBACK')).catch(() => undefined);
|
|
@@ -324,6 +369,16 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
324
369
|
}
|
|
325
370
|
}
|
|
326
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Marks the open root as one that must reach its COMMIT even if its scope is abandoned while it
|
|
374
|
+
* runs, and a no-op outside one. `withIdempotency` calls it straight after a reservation it created:
|
|
375
|
+
* the settle rides that COMMIT and the replay answers from it, so one mark decides both.
|
|
376
|
+
*/
|
|
377
|
+
export function completeTransaction(): void {
|
|
378
|
+
const state = storage.get();
|
|
379
|
+
if (state?.live.value === true) state.completing.value = true;
|
|
380
|
+
}
|
|
381
|
+
|
|
327
382
|
export async function withTransaction<T>(
|
|
328
383
|
fn: (tx: DbTx) => Promise<T>,
|
|
329
384
|
options: TransactionOptions = {},
|
package/src/ungeneratable.ts
CHANGED
|
@@ -43,10 +43,18 @@ export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
|
|
|
43
43
|
{ name: 'create index', pattern: /^create\s+index\b/ },
|
|
44
44
|
{ name: 'create unique index', pattern: /^create\s+unique\s+index\b/ },
|
|
45
45
|
{ name: 'drop index', pattern: /^drop\s+index\b/ },
|
|
46
|
+
// `generate-view.ts` writes both kinds, and rebuilds by drop + create — never `or replace`, never
|
|
47
|
+
// `refresh` — so those two stay hand-written and stay counted. A materialized view's indexes are
|
|
48
|
+
// `create [unique] index`, already above.
|
|
49
|
+
{ name: 'create view', pattern: /^create\s+view\b/ },
|
|
50
|
+
{ name: 'create materialized view', pattern: /^create\s+materialized\s+view\b/ },
|
|
51
|
+
{ name: 'drop view', pattern: /^drop\s+view\b/ },
|
|
52
|
+
{ name: 'drop materialized view', pattern: /^drop\s+materialized\s+view\b/ },
|
|
46
53
|
{ name: 'add column', pattern: /^alter\s+table\s[\s\S]*?\badd\s+column\b/ },
|
|
47
54
|
{ name: 'drop column', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+column\b/ },
|
|
48
55
|
{ name: 'add constraint', pattern: /^alter\s+table\s[\s\S]*?\badd\s+constraint\b/ },
|
|
49
56
|
{ name: 'drop constraint', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+constraint\b/ },
|
|
57
|
+
{ name: 'validate constraint', pattern: /^alter\s+table\s[\s\S]*?\bvalidate\s+constraint\b/ },
|
|
50
58
|
{
|
|
51
59
|
name: 'alter column type',
|
|
52
60
|
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\btype\b/,
|
|
@@ -81,6 +89,28 @@ export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
|
|
|
81
89
|
// read them as generatable and let a squash discard the replication setup in silence.
|
|
82
90
|
pattern: /^alter\s+table\s[\s\S]*?\breplica\s+identity\s+(?:full|default)\b/,
|
|
83
91
|
},
|
|
92
|
+
// The lifecycle triggers (`generate-triggers.ts`, `generate-history.ts`, `generate-counter.ts`,
|
|
93
|
+
// `generate-append-only.ts`), read on BLANKED text: every name is a quoted identifier (`""`) and
|
|
94
|
+
// every body a dollar quote, so each pattern is the whole statement's shape. Absent until
|
|
95
|
+
// 2026-11-06, so `x db gen` wrote 11 statements its own rail reported. An unquoted, hand-written
|
|
96
|
+
// trigger or function, and an `update` that is not the recount, stay counted.
|
|
97
|
+
{
|
|
98
|
+
name: 'create or replace function … returns trigger',
|
|
99
|
+
pattern:
|
|
100
|
+
/^create\s+or\s+replace\s+function\s+""\s*\(\s*\)\s+returns\s+trigger\s+language\s+plpgsql\s+as\s*$/,
|
|
101
|
+
},
|
|
102
|
+
{ name: 'create trigger', pattern: /^create\s+trigger\s+""\s+(?:before|after)\s/ },
|
|
103
|
+
{ name: 'drop trigger if exists', pattern: /^drop\s+trigger\s+if\s+exists\s+""\s+on\s+""$/ },
|
|
104
|
+
// The recount's cross-tenant switch, transaction-local (`true`): a setting, never a write.
|
|
105
|
+
{
|
|
106
|
+
name: 'select set_config(…, true)',
|
|
107
|
+
pattern: /^select\s+set_config\s*\(\s*''\s*,\s*''\s*,\s*true\s*\)$/,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'update … recount',
|
|
111
|
+
pattern:
|
|
112
|
+
/^update\s+""\s+p\s+set\s+""\s*=\s*\(\s*select\s+count\s*\(\s*\*\s*\)::integer\s+from\s+""\s+c\s+where\s/,
|
|
113
|
+
},
|
|
84
114
|
];
|
|
85
115
|
|
|
86
116
|
/**
|