@lossless.org/client 1.11.0 → 1.13.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.
@@ -11,6 +11,7 @@ import {
11
11
  SmartdataPersistenceError,
12
12
  createSmartdataTimeoutError,
13
13
  isMongoDeadlineError,
14
+ isMongoTransientTransactionError,
14
15
  normalizeDeadlineError,
15
16
  normalizeOrdinaryPersistenceError,
16
17
  } from './classes.persistence.js';
@@ -31,12 +32,75 @@ interface ISmartdataSessionBinding {
31
32
  transactionSignal?: AbortSignal;
32
33
  transactionAttempt?: symbol;
33
34
  transactionFailure?: { error: unknown };
35
+ /** Set once the current attempt's callback returned and the driver moves on to commit. */
36
+ transactionCommitReached: boolean;
37
+ /** Set when the session ended while a transaction ran, stating how far the transaction had got. */
38
+ transactionEnded?: TSmartdataTransactionEnd;
34
39
  operationSettled?: Promise<void>;
35
40
  resolveOperationSettled?: () => void;
36
41
  invalidated: boolean;
37
42
  closing?: Promise<void>;
38
43
  }
39
44
 
45
+ /** How far a transaction had got when its session ended outside the callback's control. */
46
+ type TSmartdataTransactionEnd = 'before_commit' | 'during_commit';
47
+
48
+ /**
49
+ * Whether a transaction that `SmartdataSession.withTransaction()` rejected
50
+ * could have committed:
51
+ *
52
+ * - `not_committed`: nothing of the transaction was committed. The call failed
53
+ * before the driver sent a commit, or the database answered the commit with
54
+ * an error that states the transaction was aborted.
55
+ * - `unknown`: a commit was sent and its outcome was never confirmed; it may
56
+ * have applied.
57
+ */
58
+ export type TSmartdataTransactionCommitOutcome = 'not_committed' | 'unknown';
59
+
60
+ /** The commit outcome of every value a `withTransaction()` call rejected with. */
61
+ const transactionCommitOutcomes = new WeakMap<object, TSmartdataTransactionCommitOutcome>();
62
+
63
+ /**
64
+ * Records what `errorArg`, the rejection of a `withTransaction()` call, means
65
+ * for the commit, and returns it for the throw. A value several transactions
66
+ * rejected with keeps `unknown` once any of them may have committed. A
67
+ * primitive cannot be recorded.
68
+ */
69
+ const rejectTransaction = (
70
+ errorArg: unknown,
71
+ outcomeArg: TSmartdataTransactionCommitOutcome,
72
+ ): unknown => {
73
+ if (
74
+ ((typeof errorArg === 'object' && errorArg !== null) || typeof errorArg === 'function')
75
+ && transactionCommitOutcomes.get(errorArg) !== 'unknown'
76
+ ) {
77
+ transactionCommitOutcomes.set(errorArg, outcomeArg);
78
+ }
79
+ return errorArg;
80
+ };
81
+
82
+ const commitOutcomeOfTransactionEnd = (
83
+ endArg: TSmartdataTransactionEnd,
84
+ ): TSmartdataTransactionCommitOutcome =>
85
+ endArg === 'during_commit' ? 'unknown' : 'not_committed';
86
+
87
+ /**
88
+ * The session of a transaction ended while it ran, for example through the
89
+ * database's `close()`. The driver then returns the callback's result without
90
+ * committing, so the call must reject instead.
91
+ */
92
+ const createTransactionSessionEndedError = (
93
+ endArg: TSmartdataTransactionEnd,
94
+ causeArg?: unknown,
95
+ ): SmartdataPersistenceError =>
96
+ new SmartdataPersistenceError(
97
+ 'unsupported_operation',
98
+ endArg === 'during_commit'
99
+ ? 'SmartData transaction session ended during commit; the commit outcome is unknown.'
100
+ : 'SmartData transaction session ended before commit; nothing was committed.',
101
+ causeArg === undefined ? undefined : { cause: causeArg },
102
+ );
103
+
40
104
  const bindings = new WeakMap<SmartdataSession, ISmartdataSessionBinding>();
41
105
  const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
42
106
  const smartdataSessionInvalidationAggregateBrand = Symbol(
@@ -130,6 +194,30 @@ const normalizeTransactionOptions = (
130
194
  };
131
195
  };
132
196
 
197
+ /** Everything `withTransaction()` refuses before a transaction starts. */
198
+ const admitTransaction = (
199
+ sessionArg: SmartdataSession,
200
+ callbackArg: unknown,
201
+ optionsArg: ISmartdataTransactionOptions | undefined,
202
+ ): { binding: ISmartdataSessionBinding; timeoutMS?: number; signal?: AbortSignal } => {
203
+ if (typeof callbackArg !== 'function') {
204
+ throw new SmartdataPersistenceError(
205
+ 'invalid_argument',
206
+ 'SmartData transaction callback must be a function.',
207
+ );
208
+ }
209
+ const { timeoutMS, signal } = normalizeTransactionOptions(optionsArg);
210
+ const binding = resolveBinding(sessionArg);
211
+ if (binding.operationActive || binding.transactionActive) {
212
+ throw new SmartdataPersistenceError(
213
+ 'unsupported_operation',
214
+ 'Parallel or nested SmartData session operations are not supported.',
215
+ );
216
+ }
217
+ signal?.throwIfAborted();
218
+ return { binding, timeoutMS, signal };
219
+ };
220
+
133
221
  const assertTransactionSucceeded = (bindingArg: ISmartdataSessionBinding): void => {
134
222
  if (bindingArg.transactionFailure) {
135
223
  throw bindingArg.transactionFailure.error;
@@ -137,6 +225,25 @@ const assertTransactionSucceeded = (bindingArg: ISmartdataSessionBinding): void
137
225
  };
138
226
 
139
227
  export class SmartdataSession {
228
+ /**
229
+ * Whether the transaction whose `withTransaction()` call rejected with
230
+ * `errorArg` could have committed: `not_committed` when nothing of it was
231
+ * committed, `unknown` when a commit was sent and never confirmed, so it may
232
+ * have applied. Every rejection of `withTransaction()` carries one, whatever
233
+ * it is: the client's own errors, the callback's error, the abort reason or
234
+ * a driver error, which all keep their identity. A value several calls
235
+ * rejected with reads `unknown` once any of them may have committed.
236
+ *
237
+ * Returns `undefined` for any other value, and for a primitive rejection —
238
+ * a callback that throws a string, or a string abort reason — which cannot
239
+ * carry the outcome; treat `undefined` as `unknown`.
240
+ */
241
+ public static getCommitOutcome(errorArg: unknown): TSmartdataTransactionCommitOutcome | undefined {
242
+ return (typeof errorArg === 'object' && errorArg !== null) || typeof errorArg === 'function'
243
+ ? transactionCommitOutcomes.get(errorArg)
244
+ : undefined;
245
+ }
246
+
140
247
  /**
141
248
  * Runs `callbackArg` in a transaction, retrying it as the driver decides.
142
249
  *
@@ -162,42 +269,50 @@ export class SmartdataSession {
162
269
  * Any operation on the session that its deadline or a signal interrupts
163
270
  * fails the attempt: the call rolls it back and rejects with that
164
271
  * operation's error even when the callback caught it.
272
+ *
273
+ * When the session ends while the call runs — the database's `close()`
274
+ * ends every session — the call never reports a commit. It rejects with
275
+ * `unsupported_operation`: before the callback returned nothing was
276
+ * committed, afterwards the commit outcome is unknown. The callback's
277
+ * error, if it threw, is the `cause`.
278
+ *
279
+ * Every rejection states whether the transaction could have committed:
280
+ * `SmartdataSession.getCommitOutcome(error)` returns `not_committed` or
281
+ * `unknown` for the value the call rejected with.
165
282
  */
166
283
  public async withTransaction<TResult>(
167
284
  callbackArg: (sessionArg: SmartdataSession) => Promise<TResult>,
168
285
  optionsArg?: ISmartdataTransactionOptions,
169
286
  ): Promise<TResult | undefined> {
170
- if (typeof callbackArg !== 'function') {
171
- throw new SmartdataPersistenceError(
172
- 'invalid_argument',
173
- 'SmartData transaction callback must be a function.',
174
- );
175
- }
176
- const { timeoutMS, signal } = normalizeTransactionOptions(optionsArg);
177
- const binding = resolveBinding(this);
178
- if (binding.operationActive || binding.transactionActive) {
179
- throw new SmartdataPersistenceError(
180
- 'unsupported_operation',
181
- 'Parallel or nested SmartData session operations are not supported.',
182
- );
287
+ let admitted: ReturnType<typeof admitTransaction>;
288
+ try {
289
+ admitted = admitTransaction(this, callbackArg, optionsArg);
290
+ } catch (errorArg) {
291
+ throw rejectTransaction(errorArg, 'not_committed');
183
292
  }
184
- signal?.throwIfAborted();
293
+ const { binding, timeoutMS, signal } = admitted;
185
294
  binding.transactionActive = true;
186
295
  binding.transactionTimeoutMS = timeoutMS;
187
296
  binding.transactionSignal = signal;
297
+ binding.transactionEnded = undefined;
188
298
  let callbackError: unknown;
189
- let commitReached = false;
299
+ /** The signal or deadline interruption that failed the last attempt. */
300
+ let attemptInterruption: { error: unknown } | undefined;
301
+ /** The session-ended rejection the last attempt failed with, and how far that transaction had got. */
302
+ let sessionEnded: { error: SmartdataPersistenceError; end: TSmartdataTransactionEnd } | undefined;
190
303
  const runAttempt = async () => {
191
304
  const attempt = Symbol('smartdataTransactionAttempt');
192
305
  binding.transactionAttempt = attempt;
193
306
  binding.transactionCallbackOpen = true;
194
307
  binding.transactionUsesOrdinaryWrite = false;
195
308
  binding.transactionFailure = undefined;
309
+ binding.transactionCommitReached = false;
196
310
  callbackError = undefined;
197
- commitReached = false;
311
+ attemptInterruption = undefined;
198
312
  try {
199
313
  let result: TResult;
200
314
  let callbackReturned = false;
315
+ let callbackThrown: { error: unknown } | undefined;
201
316
  try {
202
317
  // A retry the driver starts after an abort ends before the callback.
203
318
  signal?.throwIfAborted();
@@ -206,6 +321,9 @@ export class SmartdataSession {
206
321
  async () => callbackArg(this),
207
322
  );
208
323
  callbackReturned = true;
324
+ } catch (errorArg) {
325
+ callbackThrown = { error: errorArg };
326
+ throw errorArg;
209
327
  } finally {
210
328
  const activeOperation = binding.operationActive
211
329
  ? binding.operationSettled
@@ -217,7 +335,20 @@ export class SmartdataSession {
217
335
  if (activeOperation) {
218
336
  await activeOperation;
219
337
  }
338
+ attemptInterruption = binding.transactionFailure;
220
339
  assertTransactionSucceeded(binding);
340
+ if (binding.transactionEnded) {
341
+ // The driver would commit or return the result on a session that is
342
+ // being ended; the attempt fails instead, keeping the callback's error.
343
+ sessionEnded = {
344
+ error: createTransactionSessionEndedError(
345
+ binding.transactionEnded,
346
+ callbackThrown?.error,
347
+ ),
348
+ end: binding.transactionEnded,
349
+ };
350
+ throw sessionEnded.error;
351
+ }
221
352
  if (callbackReturned && activeOperation) {
222
353
  throw new SmartdataPersistenceError(
223
354
  'unsupported_operation',
@@ -227,45 +358,107 @@ export class SmartdataSession {
227
358
  }
228
359
  // An abort that arrived while the callback ran rolls the attempt back.
229
360
  signal?.throwIfAborted();
230
- // The driver commits next; a deadline that expires from here on
231
- // leaves the commit outcome unknown.
232
- commitReached = true;
361
+ // The driver commits next; a deadline that expires or a session that
362
+ // ends from here on leaves the commit outcome unknown.
363
+ binding.transactionCommitReached = true;
233
364
  return result;
234
365
  } catch (errorArg) {
235
366
  callbackError = errorArg;
367
+ if (signal?.aborted && errorArg === signal.reason) {
368
+ attemptInterruption ??= { error: errorArg };
369
+ }
236
370
  throw errorArg;
237
371
  }
238
372
  };
239
- try {
240
- return timeoutMS === undefined
241
- ? await binding.rawSession.withTransaction(runAttempt)
242
- : await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
243
- } catch (errorArg) {
373
+ /**
374
+ * What the call rejects with when the driver's `withTransaction()` rejected
375
+ * with `errorArg`, and whether the transaction could have committed. Once
376
+ * the last attempt's callback returned, the driver was committing: every
377
+ * error from there on leaves the commit outcome unknown, unless it states
378
+ * that the transaction was aborted. The driver starts another attempt only
379
+ * after such an error, so an earlier attempt never committed.
380
+ */
381
+ const settleTransactionRejection = (
382
+ errorArg: unknown,
383
+ ): { error: unknown; outcome: TSmartdataTransactionCommitOutcome } => {
384
+ // The driver's closing abort replaces the attempt's error when it finds
385
+ // the transaction already aborted, as a session that close() ended leaves
386
+ // it; the interruption that failed the attempt keeps its rejection.
387
+ if (attemptInterruption) {
388
+ return { error: normalizeDeadlineError(attemptInterruption.error), outcome: 'not_committed' };
389
+ }
390
+ if (sessionEnded) {
391
+ return { error: sessionEnded.error, outcome: commitOutcomeOfTransactionEnd(sessionEnded.end) };
392
+ }
393
+ if (
394
+ binding.transactionEnded
395
+ && errorArg !== signal?.reason
396
+ && !isMongoDeadlineError(errorArg)
397
+ && !(errorArg instanceof SmartdataPersistenceError && errorArg.code === 'timeout')
398
+ ) {
399
+ return {
400
+ error: createTransactionSessionEndedError(binding.transactionEnded, errorArg),
401
+ outcome: commitOutcomeOfTransactionEnd(binding.transactionEnded),
402
+ };
403
+ }
244
404
  if (errorArg === callbackError) {
245
- throw normalizeDeadlineError(errorArg);
405
+ return { error: normalizeDeadlineError(errorArg), outcome: 'not_committed' };
246
406
  }
407
+ const outcome: TSmartdataTransactionCommitOutcome =
408
+ binding.transactionCommitReached && !isMongoTransientTransactionError(errorArg)
409
+ ? 'unknown'
410
+ : 'not_committed';
247
411
  if (isMongoDeadlineError(errorArg)) {
248
- throw createSmartdataTimeoutError(
249
- commitReached
250
- ? 'SmartData transaction exceeded its client deadline during commit; the commit outcome is unknown.'
251
- : 'SmartData transaction exceeded its client deadline before commit; nothing was committed.',
252
- errorArg,
253
- );
412
+ return {
413
+ error: createSmartdataTimeoutError(
414
+ outcome === 'unknown'
415
+ ? 'SmartData transaction exceeded its client deadline during commit; the commit outcome is unknown.'
416
+ : binding.transactionCommitReached
417
+ ? 'SmartData transaction exceeded its client deadline after the database aborted its commit; nothing was committed.'
418
+ : 'SmartData transaction exceeded its client deadline before commit; nothing was committed.',
419
+ errorArg,
420
+ ),
421
+ outcome,
422
+ };
254
423
  }
255
424
  if (binding.transactionUsesOrdinaryWrite) {
256
- return normalizeOrdinaryPersistenceError(
257
- errorArg,
258
- 'SmartData transaction conflicts with a unique index.',
259
- );
425
+ try {
426
+ normalizeOrdinaryPersistenceError(
427
+ errorArg,
428
+ 'SmartData transaction conflicts with a unique index.',
429
+ );
430
+ } catch (normalizedArg) {
431
+ return { error: normalizedArg, outcome };
432
+ }
260
433
  }
261
- throw errorArg;
434
+ return { error: errorArg, outcome };
435
+ };
436
+ let result: TResult | undefined;
437
+ let transactionEnded: TSmartdataTransactionEnd | undefined;
438
+ try {
439
+ result = timeoutMS === undefined
440
+ ? await binding.rawSession.withTransaction(runAttempt)
441
+ : await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
442
+ transactionEnded = binding.transactionEnded;
443
+ } catch (errorArg) {
444
+ const { error, outcome } = settleTransactionRejection(errorArg);
445
+ throw rejectTransaction(error, outcome);
262
446
  } finally {
263
447
  binding.transactionActive = false;
264
448
  binding.transactionTimeoutMS = undefined;
265
449
  binding.transactionSignal = undefined;
266
450
  binding.transactionUsesOrdinaryWrite = false;
267
451
  binding.transactionFailure = undefined;
452
+ binding.transactionCommitReached = false;
453
+ binding.transactionEnded = undefined;
268
454
  }
455
+ if (transactionEnded) {
456
+ throw rejectTransaction(
457
+ createTransactionSessionEndedError(transactionEnded),
458
+ commitOutcomeOfTransactionEnd(transactionEnded),
459
+ );
460
+ }
461
+ return result;
269
462
  }
270
463
 
271
464
  public async close(): Promise<void> {
@@ -301,6 +494,7 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
301
494
  transactionActive: false,
302
495
  transactionCallbackOpen: false,
303
496
  transactionUsesOrdinaryWrite: false,
497
+ transactionCommitReached: false,
304
498
  invalidated: false,
305
499
  };
306
500
  bindings.set(session, binding);
@@ -559,8 +753,13 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
559
753
  }
560
754
  };
561
755
 
756
+ /**
757
+ * Ends every session of the owner. `endOptionsArg.timeoutMS` is the driver's
758
+ * deadline for aborting a transaction a session still holds.
759
+ */
562
760
  export const invalidateSmartdataSessions = async (
563
761
  ownerArg: SmartdataDb,
762
+ endOptionsArg?: Pick<plugins.mongodb.EndSessionOptions, 'timeoutMS'>,
564
763
  ): Promise<void> => {
565
764
  const ownerBindings = bindingsByOwner.get(ownerArg);
566
765
  if (!ownerBindings) {
@@ -568,11 +767,11 @@ export const invalidateSmartdataSessions = async (
568
767
  }
569
768
  const sessions = [...ownerBindings];
570
769
  for (const bindingArg of sessions) {
571
- bindingArg.invalidated = true;
770
+ invalidateSessionBinding(bindingArg);
572
771
  }
573
772
  const results = await Promise.allSettled(
574
773
  sessions.map((bindingArg) =>
575
- closeSessionBinding(bindingArg),
774
+ closeSessionBinding(bindingArg, endOptionsArg),
576
775
  ),
577
776
  );
578
777
  const errors = results.flatMap((resultArg) =>
@@ -589,12 +788,28 @@ export const invalidateSmartdataSessions = async (
589
788
  }
590
789
  };
591
790
 
791
+ /**
792
+ * Refuses every later operation on the session. A transaction still running on
793
+ * it records how far it had got, so it rejects instead of reporting a commit.
794
+ */
795
+ const invalidateSessionBinding = (bindingArg: ISmartdataSessionBinding): void => {
796
+ bindingArg.invalidated = true;
797
+ if (bindingArg.transactionActive) {
798
+ bindingArg.transactionEnded ??= bindingArg.transactionCommitReached
799
+ ? 'during_commit'
800
+ : 'before_commit';
801
+ }
802
+ };
803
+
592
804
  /** Failed cleanup remains owned and invalidated until an explicit retry succeeds. */
593
- const closeSessionBinding = async (bindingArg: ISmartdataSessionBinding): Promise<void> => {
805
+ const closeSessionBinding = async (
806
+ bindingArg: ISmartdataSessionBinding,
807
+ endOptionsArg?: Pick<plugins.mongodb.EndSessionOptions, 'timeoutMS'>,
808
+ ): Promise<void> => {
594
809
  if (bindingArg.closing) return bindingArg.closing;
595
- bindingArg.invalidated = true;
810
+ invalidateSessionBinding(bindingArg);
596
811
  const closing = Promise.resolve().then(async () => {
597
- await bindingArg.rawSession.endSession();
812
+ await bindingArg.rawSession.endSession(endOptionsArg);
598
813
  bindingsByOwner.get(bindingArg.owner)?.delete(bindingArg);
599
814
  bindings.delete(bindingArg.session);
600
815
  });