@ultimat3/db 21.0.0 → 22.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.
@@ -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
- * Is a transaction still OPEN on this async context? A different question from `currentTx() !==
106
- * undefined`, which only says a store is present — and the store survives the scope. The one
107
- * reader is `pglite.ts`'s `run()`, where the answer decides whether a statement may skip the
108
- * single session's turn queue; skipping it on a *closed* transaction is how a straggler landed
109
- * inside whichever unit of work held the connection next, committed with it, with nothing to read.
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 inLiveTx(): boolean {
114
- return storage.get()?.live.value === true;
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
- function makeTx(id: string, connection: DbClient, undos: (() => void)[], origin: DbClient): DbTx {
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 tx = makeTx(`${outer.tx.id}/${name}`, outer.connection, undos, outer.tx.origin);
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 tx = makeTx(`tx_${nanoid(12)}`, connection, undos, client);
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 = { tx, connection, undos, savepoints: { value: 0 }, live: { value: true } };
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 `inLiveTx()` is what tells them apart.
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;