@ultimat3/db 20.2.1 → 22.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 +238 -1474
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/array-parameter.ts +0 -16
- package/src/bound-parameters.ts +23 -0
- package/src/branch.ts +5 -2
- package/src/bun-sql.ts +21 -0
- package/src/client.ts +8 -2
- package/src/column-alter.ts +66 -0
- package/src/ddl-errors.ts +13 -0
- package/src/generate.ts +5 -0
- package/src/generated-column.ts +30 -8
- package/src/migrate.ts +2 -1
- package/src/pg-instant.ts +54 -0
- package/src/pglite.ts +21 -6
- package/src/replica-client.ts +28 -0
- package/src/statement-funnel.ts +6 -6
- package/src/statement-split.ts +31 -1
- package/src/transaction.ts +88 -14
package/src/transaction.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface DbTx extends DbClient {
|
|
|
35
35
|
readonly origin: DbClient;
|
|
36
36
|
/** Fired in reverse registration order when this scope rolls back. Never on commit. */
|
|
37
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;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
|
|
@@ -76,6 +84,10 @@ interface TxState {
|
|
|
76
84
|
readonly tx: DbTx;
|
|
77
85
|
readonly connection: DbClient;
|
|
78
86
|
readonly undos: (() => void)[];
|
|
87
|
+
/** This scope's `onCommit` effects; a nested scope hands its own to the parent on RELEASE. */
|
|
88
|
+
readonly commits: (() => void)[];
|
|
89
|
+
/** How the root ended — shared by reference, like `live`. */
|
|
90
|
+
readonly outcome: TxOutcome;
|
|
79
91
|
/** Shared by reference across nesting levels so savepoint names never collide. */
|
|
80
92
|
readonly savepoints: { value: number };
|
|
81
93
|
/**
|
|
@@ -102,16 +114,21 @@ export function currentTx(): DbTx | undefined {
|
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
/**
|
|
105
|
-
*
|
|
106
|
-
* undefined`, which only says a store is present — and the
|
|
107
|
-
* reader is `pglite.ts`'s `run()`, where the answer decides
|
|
108
|
-
* single session's turn queue; skipping it on a *closed*
|
|
109
|
-
* inside whichever unit of work held the connection next
|
|
117
|
+
* The connection the transaction still OPEN on this async context runs on, or `undefined`. A
|
|
118
|
+
* different question from `currentTx() !== undefined`, which only says a store is present — and the
|
|
119
|
+
* store survives the scope. The one reader is `pglite.ts`'s `run()`, where the answer decides
|
|
120
|
+
* whether a statement may skip the single session's turn queue; skipping it on a *closed*
|
|
121
|
+
* transaction is how a straggler landed inside whichever unit of work held the connection next.
|
|
110
122
|
* `currentTx()` deliberately still answers with the dead handle: its statements go through the
|
|
111
123
|
* reservation, whose own `held` fence already re-queues them.
|
|
124
|
+
*
|
|
125
|
+
* The CONNECTION, never a boolean: "is any transaction open" let an autocommit statement on
|
|
126
|
+
* client A, issued inside a transaction on client B, skip A's queue and run inside A's own open
|
|
127
|
+
* transaction — rolled back with it. The reader asks whether the connection is one of its own.
|
|
112
128
|
*/
|
|
113
|
-
export function
|
|
114
|
-
|
|
129
|
+
export function liveTxConnection(): DbClient | undefined {
|
|
130
|
+
const state = storage.get();
|
|
131
|
+
return state?.live.value === true ? state.connection : undefined;
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
/**
|
|
@@ -149,7 +166,21 @@ export function beginStatement(options: TransactionOptions): string {
|
|
|
149
166
|
return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
|
|
169
|
+
/**
|
|
170
|
+
* How the ROOT transaction ended, shared by every nested scope. An effect registered by a straggler
|
|
171
|
+
* — a promise chain `fn` forgot to await, still inside the store after the scope closed — runs at
|
|
172
|
+
* once after a COMMIT and is dropped after a ROLLBACK, rather than waiting on a list nobody reads.
|
|
173
|
+
*/
|
|
174
|
+
type TxOutcome = { value: 'open' | 'committed' | 'rolled-back' };
|
|
175
|
+
|
|
176
|
+
function makeTx(
|
|
177
|
+
id: string,
|
|
178
|
+
connection: DbClient,
|
|
179
|
+
undos: (() => void)[],
|
|
180
|
+
commits: (() => void)[],
|
|
181
|
+
origin: DbClient,
|
|
182
|
+
outcome: TxOutcome,
|
|
183
|
+
): DbTx {
|
|
153
184
|
return {
|
|
154
185
|
id,
|
|
155
186
|
origin,
|
|
@@ -159,9 +190,24 @@ function makeTx(id: string, connection: DbClient, undos: (() => void)[], origin:
|
|
|
159
190
|
onRollback: (undo: () => void) => {
|
|
160
191
|
undos.push(undo);
|
|
161
192
|
},
|
|
193
|
+
onCommit: (effect: () => void) => {
|
|
194
|
+
if (outcome.value === 'committed') runCommits([effect]);
|
|
195
|
+
else if (outcome.value === 'open') commits.push(effect);
|
|
196
|
+
},
|
|
162
197
|
};
|
|
163
198
|
}
|
|
164
199
|
|
|
200
|
+
/** Commit effects are best-effort too: the transaction is durable, and one throwing must not undo that. */
|
|
201
|
+
function runCommits(commits: readonly (() => void)[]): void {
|
|
202
|
+
for (const effect of commits) {
|
|
203
|
+
try {
|
|
204
|
+
effect();
|
|
205
|
+
} catch {
|
|
206
|
+
// swallowed deliberately — see above
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
165
211
|
/** Undo hooks are best-effort: one throwing must not mask the error that caused the rollback. */
|
|
166
212
|
function runUndos(undos: readonly (() => void)[]): void {
|
|
167
213
|
for (let index = undos.length - 1; index >= 0; index -= 1) {
|
|
@@ -177,18 +223,28 @@ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promi
|
|
|
177
223
|
outer.savepoints.value += 1;
|
|
178
224
|
const name = `x_sp_${outer.savepoints.value}`;
|
|
179
225
|
const undos: (() => void)[] = [];
|
|
180
|
-
const
|
|
226
|
+
const commits: (() => void)[] = [];
|
|
227
|
+
const tx = makeTx(
|
|
228
|
+
`${outer.tx.id}/${name}`,
|
|
229
|
+
outer.connection,
|
|
230
|
+
undos,
|
|
231
|
+
commits,
|
|
232
|
+
outer.tx.origin,
|
|
233
|
+
outer.outcome,
|
|
234
|
+
);
|
|
181
235
|
// `SAVEPOINT` and `RELEASE` are deliberately uncaught: a savepoint that was never taken means
|
|
182
236
|
// this scope never opened, and a release that failed means its work is not durable in the outer
|
|
183
237
|
// one. Both are the caller's failure to see — swallowing either would run the rest of the unit
|
|
184
238
|
// of work against a transaction that is not the one it thinks it is in.
|
|
185
239
|
await outer.connection.execute(raw(`SAVEPOINT ${name}`));
|
|
186
240
|
try {
|
|
187
|
-
const result = await storage.run({ ...outer, tx, undos }, () => fn(tx));
|
|
241
|
+
const result = await storage.run({ ...outer, tx, undos, commits }, () => fn(tx));
|
|
188
242
|
await outer.connection.execute(raw(`RELEASE SAVEPOINT ${name}`));
|
|
189
243
|
// The nested scope committed into an outer one that can still roll back, so its undos
|
|
190
|
-
// must survive: hand them to the parent rather than dropping them.
|
|
244
|
+
// must survive: hand them to the parent rather than dropping them. Its commit effects wait
|
|
245
|
+
// for the ROOT's COMMIT the same way — a released savepoint is not yet durable.
|
|
191
246
|
outer.undos.push(...undos);
|
|
247
|
+
outer.commits.push(...commits);
|
|
192
248
|
return result;
|
|
193
249
|
} catch (error) {
|
|
194
250
|
// Best-effort, exactly like the root's ROLLBACK: the savepoint is already gone when the
|
|
@@ -223,25 +279,43 @@ async function runRoot<T>(fn: (tx: DbTx) => Promise<T>, options: TransactionOpti
|
|
|
223
279
|
: undefined;
|
|
224
280
|
const connection: DbClient = reserved ?? client;
|
|
225
281
|
const undos: (() => void)[] = [];
|
|
226
|
-
const
|
|
282
|
+
const commits: (() => void)[] = [];
|
|
283
|
+
const outcome: TxOutcome = { value: 'open' };
|
|
284
|
+
const tx = makeTx(`tx_${nanoid(12)}`, connection, undos, commits, client, outcome);
|
|
227
285
|
// Each attempt gets its own state, and therefore its own `live` — a retry re-runs `fn` against a
|
|
228
286
|
// transaction that is genuinely new, so the abandoned attempt's stragglers must read as closed.
|
|
229
|
-
const state: TxState = {
|
|
287
|
+
const state: TxState = {
|
|
288
|
+
tx,
|
|
289
|
+
connection,
|
|
290
|
+
undos,
|
|
291
|
+
commits,
|
|
292
|
+
outcome,
|
|
293
|
+
savepoints: { value: 0 },
|
|
294
|
+
live: { value: true },
|
|
295
|
+
};
|
|
230
296
|
|
|
297
|
+
let committed = false;
|
|
231
298
|
try {
|
|
232
299
|
await connection.execute(raw(beginStatement(options)));
|
|
233
300
|
const result = await storage.run(state, () => fn(tx));
|
|
234
301
|
await connection.execute(raw('COMMIT'));
|
|
302
|
+
// After COMMIT answered, and outside the `catch` below: a failing effect must never be read as
|
|
303
|
+
// a failed transaction and trigger a ROLLBACK of work the server already made durable.
|
|
304
|
+
committed = true;
|
|
305
|
+
outcome.value = 'committed';
|
|
306
|
+
runCommits(commits);
|
|
235
307
|
return result;
|
|
236
308
|
} catch (error) {
|
|
309
|
+
if (committed) throw error;
|
|
237
310
|
// Best-effort: the caller needs the original failure, never the rollback's. A BEGIN that
|
|
238
311
|
// itself failed opened nothing, so this ROLLBACK is a no-op the server answers with a notice.
|
|
239
312
|
await connection.execute(raw('ROLLBACK')).catch(() => undefined);
|
|
313
|
+
outcome.value = 'rolled-back';
|
|
240
314
|
runUndos(undos);
|
|
241
315
|
throw error;
|
|
242
316
|
} finally {
|
|
243
317
|
// The scope says when it CLOSED, on every exit, because nothing else can: the store it left
|
|
244
|
-
// behind is indistinguishable from a live one, and `
|
|
318
|
+
// behind is indistinguishable from a live one, and `liveTxConnection()` tells them apart.
|
|
245
319
|
// Cleared before the `using` pin is given back, so no window exists where a straggler could
|
|
246
320
|
// still be sent direct at a connection this scope no longer owns.
|
|
247
321
|
state.live.value = false;
|