@lossless.org/client 1.21.1 → 1.22.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.
Files changed (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.js +8 -4
  3. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +19 -4
  4. package/dist_ts/nosqldb/classes.atomicupdate.js +16 -5
  5. package/dist_ts/nosqldb/classes.bootstraprecord.js +31 -5
  6. package/dist_ts/nosqldb/classes.collection.js +17 -14
  7. package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
  8. package/dist_ts/nosqldb/classes.connection.js +3 -2
  9. package/dist_ts/nosqldb/classes.db.d.ts +27 -2
  10. package/dist_ts/nosqldb/classes.db.js +42 -3
  11. package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
  12. package/dist_ts/nosqldb/classes.doc.js +15 -9
  13. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
  14. package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
  15. package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
  16. package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
  17. package/dist_ts/nosqldb/classes.readtracker.d.ts +26 -0
  18. package/dist_ts/nosqldb/classes.readtracker.js +124 -1
  19. package/dist_ts/nosqldb/classes.session.d.ts +34 -5
  20. package/dist_ts/nosqldb/classes.session.js +134 -8
  21. package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
  22. package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
  23. package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
  24. package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
  25. package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
  26. package/package.json +4 -4
  27. package/readme.md +1 -0
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  30. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
  31. package/ts/nosqldb/classes.atomicupdate.ts +25 -3
  32. package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
  33. package/ts/nosqldb/classes.collection.ts +20 -12
  34. package/ts/nosqldb/classes.connection.ts +5 -1
  35. package/ts/nosqldb/classes.db.ts +65 -2
  36. package/ts/nosqldb/classes.doc.ts +16 -8
  37. package/ts/nosqldb/classes.exactpersistence.ts +83 -21
  38. package/ts/nosqldb/classes.operationdeadline.ts +8 -0
  39. package/ts/nosqldb/classes.readtracker.ts +139 -0
  40. package/ts/nosqldb/classes.session.ts +191 -8
  41. package/ts/nosqldb/classes.storedinspection.ts +6 -3
  42. package/ts/nosqldb/classes.writeobserver.ts +5 -0
  43. package/ts/nosqldb/classes.writerevisions.ts +328 -0
@@ -34,6 +34,7 @@ import { hasSmartdataWriteObservers } from './classes.writeobserver.js';
34
34
  import {
35
35
  leaseOrdinarySmartdataSession,
36
36
  reportSmartdataCollectionWrite,
37
+ readPreviousWriteRevisions,
37
38
  runWithOrdinarySmartdataSession,
38
39
  trackSmartdataRead,
39
40
  type TSmartdataOrdinarySession,
@@ -1955,6 +1956,7 @@ export class SmartdataCollection<T> {
1955
1956
  {
1956
1957
  ordinaryWrite: optionsArg.ordinaryWrite,
1957
1958
  prepared: this.isInitializedForCurrentDatabase(),
1959
+ prepare: () => this.init(),
1958
1960
  collectionName: this.collectionName,
1959
1961
  timeoutMS: optionsArg.timeoutMS,
1960
1962
  signal: optionsArg.signal,
@@ -2423,11 +2425,11 @@ export class SmartdataCollection<T> {
2423
2425
  { ordinaryWrite: false, timeoutMS: opts?.timeoutMS },
2424
2426
  async (rawSessionArg, deadlineArg) => {
2425
2427
  await deadlineArg.join(this.init());
2426
- const tracked = trackSmartdataRead(this, opts?.session, {
2428
+ const tracked = await trackSmartdataRead(this, opts?.session, {
2427
2429
  kind: 'documents',
2428
2430
  filter: filterObject,
2429
2431
  limited: true,
2430
- });
2432
+ }, deadlineArg);
2431
2433
  const document = await this.mongoDbCollection.findOne(filterObject, {
2432
2434
  projection: opts?.projection,
2433
2435
  maxTimeMS: opts?.maxTimeMS,
@@ -2457,7 +2459,7 @@ export class SmartdataCollection<T> {
2457
2459
  });
2458
2460
  try {
2459
2461
  await this.init();
2460
- const tracked = trackSmartdataRead(this, opts?.session, {
2462
+ const tracked = await trackSmartdataRead(this, opts?.session, {
2461
2463
  kind: 'documents',
2462
2464
  filter: filterObjectArg,
2463
2465
  });
@@ -2483,10 +2485,10 @@ export class SmartdataCollection<T> {
2483
2485
  { ordinaryWrite: false, timeoutMS: opts?.timeoutMS },
2484
2486
  async (rawSessionArg, deadlineArg) => {
2485
2487
  await deadlineArg.join(this.init());
2486
- const tracked = trackSmartdataRead(this, opts?.session, {
2488
+ const tracked = await trackSmartdataRead(this, opts?.session, {
2487
2489
  kind: 'documents',
2488
2490
  filter: filterObject,
2489
- });
2491
+ }, deadlineArg);
2490
2492
  const cursor = this.mongoDbCollection.find(filterObject, {
2491
2493
  session: rawSessionArg,
2492
2494
  ...deadlineArg.cursorOptions(),
@@ -2692,11 +2694,11 @@ export class SmartdataCollection<T> {
2692
2694
  async (rawSessionArg, deadlineArg) => {
2693
2695
  await deadlineArg.join(this.init());
2694
2696
  // Totals depend on every matching document, not on which ones come back.
2695
- trackSmartdataRead(this, opts?.session, {
2697
+ await trackSmartdataRead(this, opts?.session, {
2696
2698
  kind: 'documents',
2697
2699
  filter: matchArg ?? {},
2698
2700
  matches: true,
2699
- });
2701
+ }, deadlineArg);
2700
2702
  const pipeline: plugins.mongodb.Document[] = [];
2701
2703
  if (matchArg && Object.keys(matchArg).length > 0) {
2702
2704
  pipeline.push({ $match: matchArg });
@@ -2921,6 +2923,9 @@ export class SmartdataCollection<T> {
2921
2923
  : {}),
2922
2924
  };
2923
2925
  try {
2926
+ const previousPartitionsRead = await readPreviousWriteRevisions(
2927
+ this.smartdataDb, this.mongoDbCollection, rawSessionArg, identifiableObject, deadlineArg,
2928
+ );
2924
2929
  const result = await this.mongoDbCollection.updateOne(
2925
2930
  identifiableObject,
2926
2931
  update,
@@ -2933,7 +2938,7 @@ export class SmartdataCollection<T> {
2933
2938
  result.upsertedCount > 0 ? 'insert' : 'update',
2934
2939
  [saveableObject],
2935
2940
  identifiableObject,
2936
- { images: 'none', update },
2941
+ { images: 'none', update, ...(previousPartitionsRead ? { previousPartitionsRead } : {}) },
2937
2942
  );
2938
2943
  }
2939
2944
  return result;
@@ -2963,6 +2968,9 @@ export class SmartdataCollection<T> {
2963
2968
  const observedDocument = hasSmartdataWriteObservers(this.smartdataDb)
2964
2969
  ? await dbDocArg.createSavableObject() as object
2965
2970
  : undefined;
2971
+ const previousPartitionsRead = await readPreviousWriteRevisions(
2972
+ this.smartdataDb, this.mongoDbCollection, rawSessionArg, identifiableObject, deadlineArg,
2973
+ );
2966
2974
  const result = await this.mongoDbCollection.deleteOne(identifiableObject, {
2967
2975
  session: rawSessionArg,
2968
2976
  ...deadlineArg.commandOptions(),
@@ -2974,7 +2982,7 @@ export class SmartdataCollection<T> {
2974
2982
  'delete',
2975
2983
  observedDocument ? [observedDocument] : null,
2976
2984
  identifiableObject,
2977
- { images: 'none' },
2985
+ { images: 'none', ...(previousPartitionsRead ? { previousPartitionsRead } : {}) },
2978
2986
  );
2979
2987
  }
2980
2988
  return result;
@@ -3026,11 +3034,11 @@ export class SmartdataCollection<T> {
3026
3034
  { ordinaryWrite: false, timeoutMS: opts?.timeoutMS, signal: opts?.signal },
3027
3035
  async (rawSessionArg, deadlineArg) => {
3028
3036
  await deadlineArg.join(this.init());
3029
- const tracked = trackSmartdataRead(this, opts?.session, {
3037
+ const tracked = await trackSmartdataRead(this, opts?.session, {
3030
3038
  kind: 'count',
3031
3039
  filter: filterObject,
3032
3040
  hint: opts?.hint,
3033
- });
3041
+ }, deadlineArg);
3034
3042
  const pipeline: plugins.mongodb.Document[] = [{ $match: filterObject }];
3035
3043
  if (opts?.limit !== undefined) {
3036
3044
  pipeline.push({ $limit: opts.limit });
@@ -3108,7 +3116,7 @@ export class SmartdataCollection<T> {
3108
3116
  duplicateFields: Array<{ field: string; duplicateValues: number }>;
3109
3117
  }> {
3110
3118
  await this.init();
3111
- trackSmartdataRead(this, undefined, { kind: 'collection' });
3119
+ await trackSmartdataRead(this, undefined, { kind: 'collection' });
3112
3120
  const result = {
3113
3121
  ok: true,
3114
3122
  estimatedCount: 0,
@@ -1,4 +1,5 @@
1
1
  import { SmartdataDb } from './classes.db.js';
2
+ import type { ISmartdataWriteRevisionsOptions } from './classes.writerevisions.js';
2
3
  import {
3
4
  composeSmartdataCapabilities,
4
5
  smartdataCompositeGrouping,
@@ -14,6 +15,8 @@ export interface INoSqlConnectionOptions {
14
15
  username?: string;
15
16
  password?: string;
16
17
  connectionLimit?: number;
18
+ /** See `ISmartdataDbOptions.writeRevisions`. */
19
+ writeRevisions?: ISmartdataWriteRevisionsOptions;
17
20
  }
18
21
 
19
22
  export class NoSqlConnection extends SmartdataDb {
@@ -24,7 +27,8 @@ export class NoSqlConnection extends SmartdataDb {
24
27
  constructor(public readonly options: INoSqlConnectionOptions) {
25
28
  super({ mongoDbUrl: options.url, mongoDbName: options.database,
26
29
  mongoDbUser: options.username, mongoDbPass: options.password,
27
- ...(options.connectionLimit === undefined ? {} : { maxPoolSize: options.connectionLimit }) });
30
+ ...(options.connectionLimit === undefined ? {} : { maxPoolSize: options.connectionLimit }),
31
+ ...(options.writeRevisions === undefined ? {} : { writeRevisions: options.writeRevisions }) });
28
32
  this.backend = options.backend;
29
33
  this.declaredCapabilities = composeSmartdataCapabilities(this.backend,
30
34
  smartdataCompositeGrouping(this.engineIdentity));
@@ -29,6 +29,17 @@ import {
29
29
  trackSmartdataReads,
30
30
  type ISmartdataTrackedReads,
31
31
  } from './classes.readtracker.js';
32
+ import {
33
+ bumpSmartdataWriteRevisionEpoch,
34
+ configureSmartdataWriteRevisions,
35
+ ensureSmartdataWriteRevisions,
36
+ isSmartdataWriteRevisionsActive,
37
+ type ISmartdataWriteRevisionsOptions,
38
+ } from './classes.writerevisions.js';
39
+ import {
40
+ requireOperationSignal,
41
+ requireOperationTimeoutMS,
42
+ } from './classes.operationdeadline.js';
32
43
 
33
44
  export { SmartdataSession } from './classes.session.js';
34
45
  export { SmartdataReadSet } from './classes.readtracker.js';
@@ -38,6 +49,7 @@ export type {
38
49
  TSmartdataOrdinarySession,
39
50
  TSmartdataTransactionCommitOutcome,
40
51
  } from './classes.session.js';
52
+ export type { ISmartdataWriteRevisionsOptions } from './classes.writerevisions.js';
41
53
  export type {
42
54
  ISmartdataWriteObserverOptions,
43
55
  ISmartdataWriteReport,
@@ -91,6 +103,19 @@ export interface ISmartdataDbOptions {
91
103
  serverSelectionTimeoutMS?: number;
92
104
  /** Milliseconds a socket may stay inactive before it is closed. Default 30000. */
93
105
  socketTimeoutMS?: number;
106
+ /**
107
+ * Keeps write revisions, so `reads.isCurrent()` of `trackReads()` also
108
+ * notices writes of other processes. Every process that writes the database
109
+ * must enable it with the same `partitionField`; see `trackReads()`. Read
110
+ * once, by the constructor.
111
+ */
112
+ writeRevisions?: ISmartdataWriteRevisionsOptions;
113
+ }
114
+
115
+ export interface ISmartdataInvalidateTrackedReadsOptions {
116
+ /** Client deadline of the write-revision epoch's bump (1..120000 ms). */
117
+ timeoutMS?: number;
118
+ signal?: AbortSignal;
94
119
  }
95
120
 
96
121
  export interface ISmartdataReadinessOptions {
@@ -220,6 +245,7 @@ export class SmartdataDb {
220
245
  private capabilitiesState: ICapabilities | undefined;
221
246
 
222
247
  constructor(smartdataOptions: ISmartdataDbOptions) {
248
+ configureSmartdataWriteRevisions(this, smartdataOptions.writeRevisions);
223
249
  this.smartdataOptions = smartdataOptions;
224
250
  this.status = 'initial';
225
251
  void this.statusConnectedDeferred.promise.catch(() => {});
@@ -312,6 +338,10 @@ export class SmartdataDb {
312
338
  this.applyEngineIdentity(await probeSmartdataEngineIdentity(candidateDb, budget));
313
339
  // The probe absorbs its own failures, so a budget that expired during it must still refuse here.
314
340
  budget?.check();
341
+ if (isSmartdataWriteRevisionsActive(this)) {
342
+ await ensureSmartdataWriteRevisions(candidateDb, budget?.operationOptions());
343
+ budget?.check();
344
+ }
315
345
  this.mongoDbClient = candidateClient;
316
346
  this.mongoDb = candidateDb;
317
347
  candidateClient = undefined;
@@ -562,8 +592,11 @@ export class SmartdataDb {
562
592
  * as one that did. A cache keyed by `reads` rebuilds only what such a write
563
593
  * touched.
564
594
  *
565
- * Writes of other processes, raw driver access and migrations are not
566
- * followed. A read inside a transaction, a read of another database and a
595
+ * `reads.changed` does not follow writes of other processes, raw driver
596
+ * access or migrations. With the `writeRevisions` option, the awaited
597
+ * `reads.isCurrent()` also answers for the model writes of every process
598
+ * that enables it, and for raw writes once `invalidateTrackedReads()` states
599
+ * them. A read inside a transaction, a read of another database and a
567
600
  * write made inside `fnArg` mark the set changed at once. Queries are
568
601
  * evaluated by MongoDB's simple binary comparison, so a read of a view or of
569
602
  * a collection with a default collation marks the set changed as well; each
@@ -576,6 +609,36 @@ export class SmartdataDb {
576
609
  return trackSmartdataReads(this, fnArg);
577
610
  }
578
611
 
612
+ /**
613
+ * States that something wrote the database behind its models, such as a
614
+ * migration or an operator command on the raw driver: every open read set
615
+ * of this process changes, and with `writeRevisions` active the database's
616
+ * epoch is bumped, so `isCurrent()` turns false in every process. Call it
617
+ * after the raw writes; a raw write in flight meanwhile can still be missed.
618
+ */
619
+ public async invalidateTrackedReads(
620
+ optionsArg: ISmartdataInvalidateTrackedReadsOptions = {},
621
+ ): Promise<void> {
622
+ const { timeoutMS, signal } = assertDataOptions(optionsArg, ['timeoutMS', 'signal']);
623
+ const options = {
624
+ timeoutMS: requireOperationTimeoutMS(timeoutMS, 'invalidateTrackedReads timeoutMS'),
625
+ signal: requireOperationSignal(signal, 'invalidateTrackedReads signal'),
626
+ };
627
+ changeSmartdataReadSetsOfDb(this);
628
+ if (!isSmartdataWriteRevisionsActive(this)) return;
629
+ try {
630
+ await bumpSmartdataWriteRevisionEpoch(this, {
631
+ ...(options.timeoutMS === undefined ? {} : { timeoutMS: options.timeoutMS }),
632
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
633
+ });
634
+ } catch (errorArg) {
635
+ if (isMongoDeadlineError(errorArg)) {
636
+ throw createSmartdataTimeoutError('Bumping the write-revision epoch exceeded its client deadline.', errorArg);
637
+ }
638
+ throw errorArg;
639
+ }
640
+ }
641
+
579
642
  /**
580
643
  * Creates a narrowly scoped MongoDB administration facade bound to this
581
644
  * SmartData lifecycle. Applications never need direct driver access.
@@ -30,6 +30,7 @@ import { SmartdataDbWatcher } from './classes.watcher.js';
30
30
  import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
31
31
  import { executeAtomicDelete, executeAtomicDeleteMany } from './classes.atomicdelete.js';
32
32
  import { requireOperationSignal } from './classes.operationdeadline.js';
33
+ import { isSmartdataWriteRevisionsActive } from './classes.writerevisions.js';
33
34
  import {
34
35
  executeAtomicFindOneAndReplace, executeAtomicFindOneAndUpdate,
35
36
  } from './classes.atomicfindoneandupdate.js';
@@ -4602,7 +4603,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4602
4603
  * unless the caller supplies a transaction session. With ordered semantics
4603
4604
  * (the default) MongoDB stops at the first failure; documents MongoDB
4604
4605
  * reports as inserted are marked database-backed even when the batch fails
4605
- * part-way, so callers can compensate precisely.
4606
+ * part-way, so callers can compensate precisely. On a database with active
4607
+ * write revisions, a batch outside a transaction runs in one of its own and
4608
+ * inserts every document or none.
4606
4609
  */
4607
4610
  public static async insertMany<T extends SmartDataDbDoc<any, any>>(
4608
4611
  this: plugins.tsclass.typeFest.Class<T>,
@@ -4674,6 +4677,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4674
4677
  }
4675
4678
  const collection: SmartdataCollection<T> = (this as any).collection;
4676
4679
  const inTransaction = isOrdinarySessionInTransaction(opts?.session);
4680
+ // Outside a transaction, a database with write revisions runs the batch
4681
+ // in one of its own, so a failed batch inserted nothing.
4682
+ const insertsWhole = !inTransaction && isSmartdataWriteRevisionsActive(collection.smartdataDb);
4677
4683
  const markInsertedDocuments = (
4678
4684
  insertedIdsArg: Record<number, unknown> | undefined,
4679
4685
  ): void => {
@@ -4701,7 +4707,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4701
4707
  const bulkError = error instanceof SmartdataPersistenceError
4702
4708
  ? error.cause
4703
4709
  : error;
4704
- if (bulkError instanceof plugins.mongodb.MongoBulkWriteError) {
4710
+ if (bulkError instanceof plugins.mongodb.MongoBulkWriteError && !insertsWhole) {
4705
4711
  markInsertedDocuments(
4706
4712
  bulkError.result?.insertedIds as unknown as
4707
4713
  | Record<number, unknown>
@@ -6057,12 +6063,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
6057
6063
  }, async (rawSessionArg, deadlineArg) => {
6058
6064
  await deadlineArg.join(collection.init());
6059
6065
  const sort = { [optionsArg.sortField]: sortDirection, [uniqueField]: sortDirection } as const;
6060
- const tracked = trackSmartdataRead(collection, optionsArg.session, {
6066
+ const tracked = await trackSmartdataRead(collection, optionsArg.session, {
6061
6067
  kind: 'documents',
6062
6068
  filter: selector,
6063
6069
  sort,
6064
6070
  limited: true,
6065
- });
6071
+ }, deadlineArg);
6066
6072
  const rawCursor = collection.mongoDbCollection
6067
6073
  .find(selector, { session: rawSessionArg, ...deadlineArg.cursorOptions() })
6068
6074
  .sort(sort)
@@ -6080,7 +6086,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
6080
6086
  );
6081
6087
  const lastRow: any = pageRows[pageRows.length - 1];
6082
6088
  const countTracked = optionsArg.withTotal
6083
- ? trackSmartdataRead(collection, optionsArg.session, { kind: 'count', filter: baseSelector })
6089
+ ? await trackSmartdataRead(
6090
+ collection, optionsArg.session, { kind: 'count', filter: baseSelector }, deadlineArg,
6091
+ )
6084
6092
  : undefined;
6085
6093
  const total = optionsArg.withTotal
6086
6094
  ? await collection.mongoDbCollection.countDocuments(baseSelector, {
@@ -6230,7 +6238,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
6230
6238
  const filter = (this as any).normalizeReadFilter(filterArg);
6231
6239
  // A modifier changes the query in ways a read set cannot follow, and a
6232
6240
  // skipped page depends on the documents before it.
6233
- const tracked = trackSmartdataRead(collection, session, modifier
6241
+ const tracked = await trackSmartdataRead(collection, session, modifier
6234
6242
  ? { kind: 'collection' }
6235
6243
  : { kind: 'documents', filter, sort, hint, matches: skip !== undefined && skip > 0, limited: Boolean(limit) });
6236
6244
  let rawCursor: plugins.mongodb.FindCursor<any> =
@@ -6332,9 +6340,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
6332
6340
  defaultTimeoutMS: 5_000,
6333
6341
  signal: opts.signal,
6334
6342
  }, async (rawSessionArg, deadlineArg) => {
6335
- const tracked = trackSmartdataRead(collection, opts.session, {
6343
+ const tracked = await trackSmartdataRead(collection, opts.session, {
6336
6344
  kind: 'documents', filter, sort, hint, matches: skip > 0, limited: true,
6337
- });
6345
+ }, deadlineArg);
6338
6346
  const rawCursor = collection.mongoDbCollection.find(filter, {
6339
6347
  projection, sort, hint, skip, limit, maxTimeMS,
6340
6348
  ...deadlineArg.cursorOptions(), session: rawSessionArg,
@@ -6,11 +6,14 @@ import { getOrdinaryPersistencePolicy } from './classes.ordinarypersistence.js';
6
6
  import type { SmartdataCollection } from './classes.collection.js';
7
7
  import { getIdentityIndexName } from './classes.collectiontopology.js';
8
8
  import { registerCollectionReconnectHandler } from './classes.collectionlifecycle.js';
9
- import type { SmartdataSession } from './classes.session.js';
10
9
  import {
10
+ SmartdataSession,
11
11
  acquireSmartdataSession,
12
+ needsSmartdataRevisionTransaction,
13
+ readPreviousWriteRevisions,
12
14
  reportSmartdataCollectionWrite,
13
15
  reportSmartdataWriteFailure,
16
+ runSmartdataRevisionTransaction,
14
17
  settleSmartdataOperationError,
15
18
  resolveSmartdataOperationDeadline,
16
19
  trackSmartdataRead,
@@ -1068,6 +1071,19 @@ export class SmartdataExactCollection<
1068
1071
  await this.initialize();
1069
1072
  }
1070
1073
 
1074
+ /** Refuses an owned session until the collection is prepared: its DDL cannot run in a transaction. */
1075
+ private requirePreparedForSession(): void {
1076
+ if (
1077
+ !this.collection.isInitializedForCurrentDatabase()
1078
+ || this.initializedCollection !== this.collection.mongoDbCollection
1079
+ ) {
1080
+ throw new SmartdataExactPersistenceError(
1081
+ 'unsupported_operation',
1082
+ 'Exact collection must be prepared before it participates in a transaction.',
1083
+ );
1084
+ }
1085
+ }
1086
+
1071
1087
  private async beginOperation(
1072
1088
  sessionArg: SmartdataSession | undefined,
1073
1089
  boundsArg: { timeoutMS?: number; signal?: AbortSignal },
@@ -1081,15 +1097,7 @@ export class SmartdataExactCollection<
1081
1097
  await deadline.join(this.initialize());
1082
1098
  return { deadline, release: () => {} };
1083
1099
  }
1084
- if (
1085
- !this.collection.isInitializedForCurrentDatabase()
1086
- || this.initializedCollection !== this.collection.mongoDbCollection
1087
- ) {
1088
- throw new SmartdataExactPersistenceError(
1089
- 'unsupported_operation',
1090
- 'Exact collection must be prepared before it participates in a transaction.',
1091
- );
1092
- }
1100
+ this.requirePreparedForSession();
1093
1101
  const lease = acquireSmartdataSession(
1094
1102
  sessionArg,
1095
1103
  this.collection.smartdataDb,
@@ -1141,7 +1149,8 @@ export class SmartdataExactCollection<
1141
1149
  /**
1142
1150
  * Runs one exact write through `runOperation`. A write that fails once
1143
1151
  * started may still have applied, so it changes every open read set that
1144
- * read the collection.
1152
+ * read the collection. A write of a database with write revisions outside
1153
+ * an owned transaction starts one, on the caller's session if it passed one.
1145
1154
  */
1146
1155
  private async runWriteOperation<TResult>(
1147
1156
  optionsArg: IExactBoundedOptions,
@@ -1150,6 +1159,9 @@ export class SmartdataExactCollection<
1150
1159
  deadlineArg: SmartdataOperationDeadline,
1151
1160
  ) => Promise<TResult>,
1152
1161
  ): Promise<TResult> {
1162
+ if (needsSmartdataRevisionTransaction(optionsArg.session, this.collection.smartdataDb)) {
1163
+ return this.runRevisionWriteOperation(optionsArg, operationArg);
1164
+ }
1153
1165
  let started = false;
1154
1166
  try {
1155
1167
  return await runSmartdataWrite(this.collection.smartdataDb, () =>
@@ -1167,6 +1179,39 @@ export class SmartdataExactCollection<
1167
1179
  }
1168
1180
  }
1169
1181
 
1182
+ /**
1183
+ * Runs one exact write in a transaction that carries the write's revisions:
1184
+ * on the caller's owned session, which needs the collection prepared, or on
1185
+ * a session of its own, which prepares the collection first. The
1186
+ * transaction's commit is the write's outcome: one whose commit outcome is
1187
+ * unknown is `ambiguous_write`.
1188
+ */
1189
+ private async runRevisionWriteOperation<TResult>(
1190
+ optionsArg: IExactBoundedOptions,
1191
+ operationArg: (
1192
+ sessionArg: plugins.mongodb.ClientSession | undefined,
1193
+ deadlineArg: SmartdataOperationDeadline,
1194
+ ) => Promise<TResult>,
1195
+ ): Promise<TResult> {
1196
+ const timeoutMS = requireOperationTimeoutMS(optionsArg.timeoutMS, 'Exact operation timeoutMS');
1197
+ const signal = requireOperationSignal(optionsArg.signal, 'Exact operation signal');
1198
+ signal?.throwIfAborted();
1199
+ if (optionsArg.session) this.requirePreparedForSession();
1200
+ try {
1201
+ return await runSmartdataRevisionTransaction(optionsArg.session, this.collection.smartdataDb, {
1202
+ ...(optionsArg.session ? { prepared: true } : { prepare: () => this.initialize() }),
1203
+ collectionName: this.collection.collectionName,
1204
+ ...(timeoutMS === undefined ? {} : { timeoutMS }),
1205
+ ...(signal === undefined ? {} : { signal }),
1206
+ }, (sessionArg) => this.runWriteOperation({ session: sessionArg }, operationArg));
1207
+ } catch (errorArg) {
1208
+ if (SmartdataSession.getCommitOutcome(errorArg) === 'unknown') {
1209
+ throw this.ambiguousWriteError('The commit outcome of an exact write is unknown.', errorArg);
1210
+ }
1211
+ throw errorArg;
1212
+ }
1213
+ }
1214
+
1170
1215
  /**
1171
1216
  * The error of a non-transactional write that its client deadline or
1172
1217
  * signal interrupted after the write was handed to the driver: the command
@@ -1712,11 +1757,11 @@ export class SmartdataExactCollection<
1712
1757
  ): Promise<TStoredDocument<TPersisted> | null> {
1713
1758
  return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1714
1759
  const filter = convertFilterForMongoDb(filterArg);
1715
- const tracked = trackSmartdataRead(this.collection, optionsArg.session, {
1760
+ const tracked = await trackSmartdataRead(this.collection, optionsArg.session, {
1716
1761
  kind: 'documents',
1717
1762
  filter,
1718
1763
  limited: true,
1719
- });
1764
+ }, deadlineArg);
1720
1765
  const cursor = this.collection.mongoDbCollection.find(
1721
1766
  filter,
1722
1767
  {
@@ -1758,13 +1803,13 @@ export class SmartdataExactCollection<
1758
1803
  return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1759
1804
  const filter = convertFilterForMongoDb(optionsArg.filter || {});
1760
1805
  // A skipped page depends on the documents before it, which it never returns.
1761
- const tracked = trackSmartdataRead(this.collection, optionsArg.session, {
1806
+ const tracked = await trackSmartdataRead(this.collection, optionsArg.session, {
1762
1807
  kind: 'documents',
1763
1808
  filter,
1764
1809
  sort: optionsArg.sort,
1765
1810
  matches: optionsArg.skip !== undefined && optionsArg.skip > 0,
1766
1811
  limited: optionsArg.limit !== undefined,
1767
- });
1812
+ }, deadlineArg);
1768
1813
  let cursor = this.collection.mongoDbCollection.find(
1769
1814
  filter,
1770
1815
  { session: sessionArg, ...deadlineArg.cursorOptions() },
@@ -1813,7 +1858,9 @@ export class SmartdataExactCollection<
1813
1858
  ): Promise<number> {
1814
1859
  return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1815
1860
  const filter = convertFilterForMongoDb(filterArg);
1816
- const tracked = trackSmartdataRead(this.collection, optionsArg.session, { kind: 'count', filter });
1861
+ const tracked = await trackSmartdataRead(
1862
+ this.collection, optionsArg.session, { kind: 'count', filter }, deadlineArg,
1863
+ );
1817
1864
  const count = await this.collection.mongoDbCollection.countDocuments(
1818
1865
  filter,
1819
1866
  { session: sessionArg, ...deadlineArg.commandOptions() },
@@ -1834,11 +1881,11 @@ export class SmartdataExactCollection<
1834
1881
  >>> {
1835
1882
  return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1836
1883
  const filter = convertFilterForMongoDb(filterArg);
1837
- trackSmartdataRead(this.collection, optionsArg.session, {
1884
+ await trackSmartdataRead(this.collection, optionsArg.session, {
1838
1885
  kind: 'documents',
1839
1886
  filter,
1840
1887
  matches: true,
1841
- });
1888
+ }, deadlineArg);
1842
1889
  return (await this.collection.mongoDbCollection.distinct(
1843
1890
  fieldArg as string,
1844
1891
  filter,
@@ -1904,7 +1951,12 @@ export class SmartdataExactCollection<
1904
1951
  ? { _id: current._id, _smartdataRevision: current._smartdataRevision }
1905
1952
  : { _id: current._id, _smartdataRevision: { $exists: false } };
1906
1953
 
1907
- // Only a revision pins the stored body the selector matched.
1954
+ // Only a revision pins the stored body the selector matched; without one
1955
+ // the write-revision partition it held is read first.
1956
+ const previousPartitionsRead = current._smartdataRevision === undefined
1957
+ && await readPreviousWriteRevisions(
1958
+ this.collection.smartdataDb, this.collection.mongoDbCollection, sessionArg, selector, deadlineArg,
1959
+ );
1908
1960
  const reportTransition = (): void => {
1909
1961
  reportSmartdataCollectionWrite(
1910
1962
  this.collection,
@@ -1912,7 +1964,10 @@ export class SmartdataExactCollection<
1912
1964
  'replace',
1913
1965
  [current, expectedStoredDocument],
1914
1966
  selector,
1915
- { images: current._smartdataRevision !== undefined ? 'beforeAndAfter' : 'after' },
1967
+ {
1968
+ images: current._smartdataRevision !== undefined ? 'beforeAndAfter' : 'after',
1969
+ ...(previousPartitionsRead ? { previousPartitionsRead } : {}),
1970
+ },
1916
1971
  );
1917
1972
  };
1918
1973
  const replaceOptions = {
@@ -2006,6 +2061,10 @@ export class SmartdataExactCollection<
2006
2061
  [this.reconcileBy]: identity,
2007
2062
  _smartdataRevision: { $exists: false },
2008
2063
  };
2064
+ const previousPartitionsRead = current._smartdataRevision === undefined
2065
+ && await readPreviousWriteRevisions(
2066
+ this.collection.smartdataDb, this.collection.mongoDbCollection, sessionArg, selector, deadlineArg,
2067
+ );
2009
2068
  let result: plugins.mongodb.DeleteResult;
2010
2069
  const deleteOptions = { session: sessionArg, ...deadlineArg.commandOptions() };
2011
2070
  try {
@@ -2032,7 +2091,10 @@ export class SmartdataExactCollection<
2032
2091
  'delete',
2033
2092
  [current],
2034
2093
  selector,
2035
- { images: current._smartdataRevision !== undefined ? 'before' : 'none' },
2094
+ {
2095
+ images: current._smartdataRevision !== undefined ? 'before' : 'none',
2096
+ ...(previousPartitionsRead ? { previousPartitionsRead } : {}),
2097
+ },
2036
2098
  );
2037
2099
  return { status: 'deleted', deletedCount: 1 };
2038
2100
  }
@@ -76,6 +76,14 @@ export class SmartdataOperationDeadline {
76
76
  return this.expiresAt !== undefined;
77
77
  }
78
78
 
79
+ /**
80
+ * The time left for a deadline handed on whole, such as a transaction's;
81
+ * `undefined` when unbounded. Throws `timeout` once it has passed.
82
+ */
83
+ public remainingTimeoutMS(): number | undefined {
84
+ return this.expiresAt === undefined ? undefined : this.remainingMS();
85
+ }
86
+
79
87
  /** Throws the signal's reason once the call was aborted. */
80
88
  public throwIfAborted(): void {
81
89
  this.signal?.throwIfAborted();