@lossless.org/client 1.21.0 → 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 (46) 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.collectiontopology.d.ts +8 -1
  8. package/dist_ts/nosqldb/classes.collectiontopology.js +10 -2
  9. package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
  10. package/dist_ts/nosqldb/classes.connection.js +3 -2
  11. package/dist_ts/nosqldb/classes.db.d.ts +27 -2
  12. package/dist_ts/nosqldb/classes.db.js +42 -3
  13. package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
  14. package/dist_ts/nosqldb/classes.doc.js +15 -9
  15. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
  16. package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
  17. package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
  18. package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
  19. package/dist_ts/nosqldb/classes.readtracker.d.ts +28 -0
  20. package/dist_ts/nosqldb/classes.readtracker.js +128 -5
  21. package/dist_ts/nosqldb/classes.session.d.ts +34 -5
  22. package/dist_ts/nosqldb/classes.session.js +138 -9
  23. package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
  24. package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
  25. package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
  26. package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
  27. package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
  28. package/package.json +4 -4
  29. package/readme.md +1 -0
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  32. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
  33. package/ts/nosqldb/classes.atomicupdate.ts +25 -3
  34. package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
  35. package/ts/nosqldb/classes.collection.ts +20 -12
  36. package/ts/nosqldb/classes.collectiontopology.ts +15 -1
  37. package/ts/nosqldb/classes.connection.ts +5 -1
  38. package/ts/nosqldb/classes.db.ts +65 -2
  39. package/ts/nosqldb/classes.doc.ts +16 -8
  40. package/ts/nosqldb/classes.exactpersistence.ts +83 -21
  41. package/ts/nosqldb/classes.operationdeadline.ts +8 -0
  42. package/ts/nosqldb/classes.readtracker.ts +147 -5
  43. package/ts/nosqldb/classes.session.ts +197 -11
  44. package/ts/nosqldb/classes.storedinspection.ts +6 -3
  45. package/ts/nosqldb/classes.writeobserver.ts +5 -0
  46. package/ts/nosqldb/classes.writerevisions.ts +328 -0
@@ -5,6 +5,13 @@ import type { SmartdataDb } from './classes.db.js';
5
5
  import { readBoundedInspectionCursor } from './classes.namespaceinspection.js';
6
6
  import { SmartdataPersistenceError } from './classes.persistence.js';
7
7
  import type { ISmartdataWrite } from './classes.writeobserver.js';
8
+ import {
9
+ isSmartdataWriteRevisionsActive,
10
+ readWriteRevisionSnapshot,
11
+ revisionIdOfValue,
12
+ sharedRevisionId,
13
+ writeRevisionKeysOfRead,
14
+ } from './classes.writerevisions.js';
8
15
 
9
16
  /**
10
17
  * The reads one `db.trackReads()` call made. `changed` turns true once a write
@@ -22,6 +29,42 @@ export class SmartdataReadSet {
22
29
  return !state || state.changed;
23
30
  }
24
31
 
32
+ /**
33
+ * Whether no write of any process could have changed what the reads
34
+ * returned: `changed` is false, and the write revisions the reads depend on
35
+ * are what they were before the reads ran. Requires the database's
36
+ * `writeRevisions` option and an engine with transactions; without them,
37
+ * and whenever the revisions cannot be read in time, it is false.
38
+ *
39
+ * The revisions count writes per collection and partition, not per read,
40
+ * so a write of this process that left `changed` false also makes it false
41
+ * when it wrote to a partition or collection the reads depend on.
42
+ */
43
+ public async isCurrent(): Promise<boolean> {
44
+ const state = readSetStates.get(this);
45
+ const revisions = state?.revisions;
46
+ if (!state || state.changed || !revisions || revisions.lost) return false;
47
+ if (!isSmartdataWriteRevisionsActive(state.db)) return false;
48
+ try {
49
+ // Reads still starting add snapshots; wait until the set has them all.
50
+ let count: number;
51
+ let values: (string | undefined)[];
52
+ do {
53
+ count = revisions.snapshots.size;
54
+ values = await Promise.all(revisions.snapshots.values());
55
+ if (state.changed || revisions.lost) return false;
56
+ } while (revisions.snapshots.size !== count);
57
+ if (count === 0) return true;
58
+ if (values.some((valueArg) => valueArg === undefined)) return false;
59
+ const keys = [...revisions.snapshots.keys()];
60
+ const current = await readWriteRevisionSnapshot(state.db, keys);
61
+ if (state.changed) return false;
62
+ return keys.every((keyArg, indexArg) => current.get(keyArg) === values[indexArg]);
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
25
68
  /** Stops following writes and frees the recorded reads; `changed` is true from then on. */
26
69
  public close(): void {
27
70
  const state = readSetStates.get(this);
@@ -57,6 +100,8 @@ export interface ISmartdataReadSpec {
57
100
  limited?: boolean;
58
101
  /** The model's identity fields, by which a write names a document without its `_id`. */
59
102
  identityFields?: readonly string[];
103
+ /** The identity fields a sort by orders every document; see `getOrderingIdentityFields`. */
104
+ orderingIdentityFields?: readonly string[];
60
105
  }
61
106
 
62
107
  /** @internal Records what one read returned. */
@@ -65,6 +110,11 @@ export interface ISmartdataReadRecorder {
65
110
  record(documentArg: unknown): void;
66
111
  /** The read returned everything it will return. */
67
112
  settle(): void;
113
+ /**
114
+ * Settles once the write revisions the read depends on were read; the read
115
+ * is issued after it. `undefined` when there is nothing to wait for.
116
+ */
117
+ ready?: Promise<void>;
68
118
  }
69
119
 
70
120
  // Values an image or a stored path can hold beside real values.
@@ -108,6 +158,15 @@ interface IReadSetState {
108
158
  /** The callback returned, so its read collections have been checked. */
109
159
  finished: boolean;
110
160
  reads: Map<string, IReadObservation[]>;
161
+ /** The write revisions the reads depend on; only while write revisions are active. */
162
+ revisions?: IReadSetRevisions;
163
+ }
164
+
165
+ interface IReadSetRevisions {
166
+ /** Per snapshot key, its value before the first read that depends on it; `undefined` when unknown. */
167
+ snapshots: Map<string, Promise<string | undefined>>;
168
+ /** A revision could not be read, so the set can never be current. */
169
+ lost: boolean;
111
170
  }
112
171
 
113
172
  /** What one write did to one document, as far as it is known. */
@@ -828,6 +887,7 @@ export const trackSmartdataReads = async <TResult>(
828
887
  }
829
888
  const reads = new SmartdataReadSet();
830
889
  const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
890
+ if (isSmartdataWriteRevisionsActive(dbArg)) state.revisions = { snapshots: new Map(), lost: false };
831
891
  readSetStates.set(reads, state);
832
892
  let states = readSetsByDb.get(dbArg);
833
893
  if (!states) {
@@ -865,19 +925,59 @@ export const changeSmartdataReadSetsOfDb = (dbArg: SmartdataDb): void => {
865
925
  /**
866
926
  * Whether a limited read keeps the documents its filter and sort decide: at
867
927
  * most one document matches an equality on `_id` or an identity field, and a
868
- * sort by either orders every document.
928
+ * sort by `_id` or by an identity field whose index holds every document
929
+ * orders them all.
869
930
  */
870
931
  const limitFollowsQuery = (specArg: ISmartdataReadSpec): boolean => {
871
- const uniqueFields = ['_id', ...(specArg.identityFields ?? [])];
872
932
  const filter = specArg.filter;
873
933
  const sort = specArg.sort;
874
934
  const pins = (valueArg: unknown): boolean =>
875
935
  (isFilterPrimitive(valueArg) && valueArg !== null)
876
936
  || isObjectIdLike(valueArg)
877
937
  || (isPlainRecord(valueArg) && Object.keys(valueArg).length === 1 && Object.hasOwn(valueArg, '$eq') && pins(valueArg.$eq));
878
- return uniqueFields.some((fieldArg) =>
879
- (isPlainRecord(filter) && Object.hasOwn(filter, fieldArg) && pins(filter[fieldArg]))
880
- || (isPlainRecord(sort) && Object.hasOwn(sort, fieldArg)));
938
+ return ['_id', ...(specArg.identityFields ?? [])].some((fieldArg) =>
939
+ isPlainRecord(filter) && Object.hasOwn(filter, fieldArg) && pins(filter[fieldArg]))
940
+ || ['_id', ...(specArg.orderingIdentityFields ?? [])].some((fieldArg) =>
941
+ isPlainRecord(sort) && Object.hasOwn(sort, fieldArg));
942
+ };
943
+
944
+ /**
945
+ * Reads the write revisions a read depends on that its sets have no snapshot
946
+ * of yet, once for all of them. The earliest snapshot of a key stays, so a set
947
+ * compares against the state before its first read of it.
948
+ */
949
+ const snapshotWriteRevisions = (
950
+ dbArg: SmartdataDb,
951
+ specArg: ISmartdataReadSpec,
952
+ statesArg: readonly IReadSetState[],
953
+ ): Promise<void> | undefined => {
954
+ const states = statesArg.filter((stateArg) => stateArg.revisions && !stateArg.revisions.lost);
955
+ if (states.length === 0) return undefined;
956
+ if (!isSmartdataWriteRevisionsActive(dbArg)) {
957
+ for (const state of states) state.revisions!.lost = true;
958
+ return undefined;
959
+ }
960
+ const keys = writeRevisionKeysOfRead(dbArg, specArg.collection, specArg.kind, specArg.filter);
961
+ const missing = keys.filter((keyArg) =>
962
+ states.some((stateArg) => !stateArg.revisions!.snapshots.has(keyArg)));
963
+ if (missing.length === 0) return undefined;
964
+ const snapshot = (async () => readWriteRevisionSnapshot(dbArg, missing))()
965
+ .catch(() => undefined);
966
+ const pending: Promise<unknown>[] = [];
967
+ for (const state of states) {
968
+ const revisions = state.revisions!;
969
+ for (const key of missing) {
970
+ if (revisions.snapshots.has(key)) continue;
971
+ const value = snapshot.then((snapshotArg) => {
972
+ const known = snapshotArg?.get(key);
973
+ if (known === undefined) revisions.lost = true;
974
+ return known;
975
+ });
976
+ revisions.snapshots.set(key, value);
977
+ pending.push(value);
978
+ }
979
+ }
980
+ return Promise.all(pending).then(() => undefined);
881
981
  };
882
982
 
883
983
  /**
@@ -945,6 +1045,7 @@ export const observeSmartdataRead = (
945
1045
  reads.push(observation);
946
1046
  }
947
1047
  return {
1048
+ ready: snapshotWriteRevisions(dbArg, specArg, followed),
948
1049
  record: (documentArg) => {
949
1050
  if (observation.settled) return;
950
1051
  if (!isPlainRecord(documentArg)) {
@@ -1026,3 +1127,44 @@ export const captureSmartdataCollectionChange = (
1026
1127
  if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
1027
1128
  return () => applyChanges(dbArg, collectionArg, [{ kind: 'unknown', touched: 'all', startedAt: 0 }]);
1028
1129
  };
1130
+
1131
+ /**
1132
+ * @internal The write-revision counters one acknowledged write bumps: the
1133
+ * partition of each document state it names before and after, and the shared
1134
+ * counter for each state it cannot place. A state before the write that the
1135
+ * report does not carry was read ahead of the write when
1136
+ * `previousPartitionsRead` is set; a field the write left alone keeps the
1137
+ * value it has afterwards.
1138
+ */
1139
+ export const writeRevisionIdsOfWrite = (
1140
+ writeArg: ISmartdataWrite,
1141
+ fieldArg: string,
1142
+ ): Set<string> => {
1143
+ const collection = writeArg.collection;
1144
+ const ids = new Set<string>();
1145
+ const add = (valueArg: unknown) => {
1146
+ ids.add(valueArg === UNKNOWN
1147
+ ? sharedRevisionId(collection)
1148
+ : revisionIdOfValue(collection, valueArg === MISSING ? undefined : valueArg));
1149
+ };
1150
+ for (const change of describeWrite(writeArg, 0)) {
1151
+ if (change.kind === 'unknown') {
1152
+ ids.add(sharedRevisionId(collection));
1153
+ continue;
1154
+ }
1155
+ const fieldTouched = change.touched === 'all'
1156
+ || [...change.touched].some((pathArg) => pathsOverlap(pathArg, fieldArg));
1157
+ const after = change.kind === 'delete' ? undefined : lookupPath(change.after, fieldArg);
1158
+ if (change.kind !== 'delete' && (after !== UNKNOWN || fieldTouched || change.kind === 'insert')) {
1159
+ add(after);
1160
+ }
1161
+ if (change.kind === 'insert') continue;
1162
+ const before = lookupPath(change.before, fieldArg);
1163
+ if (before !== UNKNOWN) {
1164
+ add(before);
1165
+ } else if (!writeArg.previousPartitionsRead && (fieldTouched || after === UNKNOWN)) {
1166
+ add(UNKNOWN);
1167
+ }
1168
+ }
1169
+ return ids;
1170
+ };
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
 
3
3
  import type { SmartdataCollection } from './classes.collection.js';
4
+ import { getOrderingIdentityFields } from './classes.collectiontopology.js';
4
5
  import type { SmartdataDb } from './classes.db.js';
5
6
  import {
6
7
  SmartdataOperationDeadline,
@@ -29,9 +30,17 @@ import {
29
30
  hasSmartdataReadSets,
30
31
  observeSmartdataRead,
31
32
  runSmartdataWrite,
33
+ writeRevisionIdsOfWrite,
32
34
  type ISmartdataReadRecorder,
33
35
  type ISmartdataReadSpec,
34
36
  } from './classes.readtracker.js';
37
+ import {
38
+ flushSmartdataWriteRevisions,
39
+ isSmartdataWriteRevisionsActive,
40
+ readPreviousRevisionIds,
41
+ sharedRevisionId,
42
+ writeRevisionsPartitionField,
43
+ } from './classes.writerevisions.js';
35
44
 
36
45
  interface ISmartdataSessionBinding {
37
46
  session: SmartdataSession;
@@ -59,6 +68,8 @@ interface ISmartdataSessionBinding {
59
68
  * when the commit outcome is unknown.
60
69
  */
61
70
  transactionWrites: ISmartdataTransactionWrite[];
71
+ /** The write-revision counters the current attempt's writes bump right before its commit. */
72
+ revisionBumps: Set<string>;
62
73
  operationSettled?: Promise<void>;
63
74
  resolveOperationSettled?: () => void;
64
75
  invalidated: boolean;
@@ -135,6 +146,8 @@ const createTransactionSessionEndedError = (
135
146
  );
136
147
 
137
148
  const bindings = new WeakMap<SmartdataSession, ISmartdataSessionBinding>();
149
+ /** The binding of each owned session's driver session, for writes that only hold the latter. */
150
+ const bindingsByRawSession = new WeakMap<plugins.mongodb.ClientSession, ISmartdataSessionBinding>();
138
151
  const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
139
152
  const smartdataSessionInvalidationAggregateBrand = Symbol(
140
153
  'smartdataSessionInvalidationAggregate',
@@ -155,6 +168,11 @@ interface ISmartdataSessionAcquireOptions {
155
168
  interface IOrdinarySessionOperationOptions {
156
169
  ordinaryWrite?: boolean;
157
170
  prepared?: boolean;
171
+ /**
172
+ * Prepares the collection. A write of a database with write revisions that
173
+ * runs in a transaction of its own prepares first, outside it.
174
+ */
175
+ prepare?: () => Promise<void>;
158
176
  /** Names the collection whose preparation a refusal must ask for. */
159
177
  collectionName?: string;
160
178
  /** The caller's validated client deadline for the call's database work. */
@@ -185,6 +203,16 @@ const transactionContext = new plugins.nodeAsyncHooks.AsyncLocalStorage<
185
203
  ITransactionContext
186
204
  >();
187
205
 
206
+ /**
207
+ * The session of the transaction a write of a database with write revisions
208
+ * runs in when its caller passed none, so the write's reports and reads
209
+ * belong to that transaction.
210
+ */
211
+ const revisionTransactionScope = new plugins.nodeAsyncHooks.AsyncLocalStorage<{
212
+ owner: SmartdataDb;
213
+ session: SmartdataSession;
214
+ }>();
215
+
188
216
  const resolveBinding = (
189
217
  sessionArg: SmartdataSession,
190
218
  ownerArg?: SmartdataDb,
@@ -341,6 +369,7 @@ export class SmartdataSession {
341
369
  binding.transactionFailure = undefined;
342
370
  binding.transactionCommitReached = false;
343
371
  binding.transactionWrites = [];
372
+ binding.revisionBumps = new Set();
344
373
  callbackError = undefined;
345
374
  attemptInterruption = undefined;
346
375
  try {
@@ -392,6 +421,16 @@ export class SmartdataSession {
392
421
  }
393
422
  // An abort that arrived while the callback ran rolls the attempt back.
394
423
  signal?.throwIfAborted();
424
+ // The writes' revisions commit with them. A failure fails the attempt
425
+ // before its commit, and a transient one is retried with it.
426
+ if (binding.revisionBumps.size > 0) {
427
+ await flushSmartdataWriteRevisions(
428
+ binding.client.db(binding.owner.smartdataOptions.mongoDbName),
429
+ binding.rawSession,
430
+ binding.revisionBumps,
431
+ signal,
432
+ );
433
+ }
395
434
  // The driver commits next; a deadline that expires or a session that
396
435
  // ends from here on leaves the commit outcome unknown.
397
436
  binding.transactionCommitReached = true;
@@ -489,6 +528,7 @@ export class SmartdataSession {
489
528
  binding.transactionCommitReached = false;
490
529
  binding.transactionEnded = undefined;
491
530
  binding.transactionWrites = [];
531
+ binding.revisionBumps = new Set();
492
532
  }
493
533
  if (transactionEnded) {
494
534
  const outcome = commitOutcomeOfTransactionEnd(transactionEnded);
@@ -537,9 +577,11 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
537
577
  transactionUsesOrdinaryWrite: false,
538
578
  transactionCommitReached: false,
539
579
  transactionWrites: [],
580
+ revisionBumps: new Set(),
540
581
  invalidated: false,
541
582
  };
542
583
  bindings.set(session, binding);
584
+ bindingsByRawSession.set(binding.rawSession, binding);
543
585
  let ownerBindings = bindingsByOwner.get(ownerArg);
544
586
  if (!ownerBindings) {
545
587
  ownerBindings = new Set();
@@ -779,12 +821,90 @@ export const settleSmartdataOperationError = (
779
821
  return error;
780
822
  };
781
823
 
824
+ /**
825
+ * The session of the transaction a write of a database with write revisions
826
+ * runs in on its caller's behalf, while that transaction runs.
827
+ */
828
+ const revisionTransactionSessionOf = (ownerArg: SmartdataDb): SmartdataSession | undefined => {
829
+ const scope = revisionTransactionScope.getStore();
830
+ return scope?.owner === ownerArg && bindings.get(scope.session)?.transactionActive
831
+ ? scope.session
832
+ : undefined;
833
+ };
834
+
835
+ /**
836
+ * Whether a write must start a transaction of its own to carry its write
837
+ * revisions: the database keeps them and the write runs outside an owned
838
+ * transaction. A raw driver session cannot carry them and is refused.
839
+ */
840
+ export const needsSmartdataRevisionTransaction = (
841
+ sessionArg: TSmartdataOrdinarySession | undefined,
842
+ ownerArg: SmartdataDb,
843
+ ): boolean => {
844
+ if (!isSmartdataWriteRevisionsActive(ownerArg)) return false;
845
+ if (sessionArg !== undefined && !isSmartdataSession(sessionArg)) {
846
+ throw new SmartdataPersistenceError(
847
+ 'unsupported_operation',
848
+ 'Writes of a database with write revisions run on sessions of db.createSession(), not raw driver sessions.',
849
+ );
850
+ }
851
+ return sessionArg === undefined || !resolveBinding(sessionArg, ownerArg).transactionActive;
852
+ };
853
+
854
+ const transactionOptionsOf = (
855
+ timeoutMSArg: number | undefined,
856
+ signalArg: AbortSignal | undefined,
857
+ ): ISmartdataTransactionOptions => ({
858
+ ...(timeoutMSArg === undefined ? {} : { timeoutMS: timeoutMSArg }),
859
+ ...(signalArg === undefined ? {} : { signal: signalArg }),
860
+ });
861
+
862
+ /**
863
+ * Runs one write of a database with write revisions in a transaction, so its
864
+ * revisions commit with it: on the caller's owned session, or on a session of
865
+ * its own that ends with the call. The call's deadline and signal bound the
866
+ * whole transaction; the collection is prepared before it starts, since its
867
+ * DDL cannot run inside.
868
+ */
869
+ export const runSmartdataRevisionTransaction = async <TResult>(
870
+ sessionArg: SmartdataSession | undefined,
871
+ ownerArg: SmartdataDb,
872
+ optionsArg: Pick<
873
+ IOrdinarySessionOperationOptions,
874
+ 'prepared' | 'prepare' | 'collectionName' | 'timeoutMS' | 'defaultTimeoutMS' | 'signal'
875
+ >,
876
+ writeArg: (sessionArg: SmartdataSession) => Promise<TResult>,
877
+ ): Promise<TResult> => {
878
+ const timeoutMS = optionsArg.timeoutMS ?? optionsArg.defaultTimeoutMS;
879
+ if (sessionArg) {
880
+ if (!optionsArg.prepared) refuseUnpreparedCollection(optionsArg, 'with an owned SmartData session');
881
+ return await sessionArg.withTransaction(writeArg, transactionOptionsOf(timeoutMS, optionsArg.signal)) as TResult;
882
+ }
883
+ const deadline = new SmartdataOperationDeadline(timeoutMS, optionsArg.signal);
884
+ if (!optionsArg.prepared) {
885
+ if (!optionsArg.prepare) refuseUnpreparedCollection(optionsArg, 'inside a transaction');
886
+ await deadline.join(optionsArg.prepare!());
887
+ }
888
+ const transactionOptions = transactionOptionsOf(deadline.remainingTimeoutMS(), optionsArg.signal);
889
+ const session = createSmartdataSession(ownerArg);
890
+ try {
891
+ return await revisionTransactionScope.run(
892
+ { owner: ownerArg, session },
893
+ () => session.withTransaction(writeArg, transactionOptions),
894
+ ) as TResult;
895
+ } finally {
896
+ // A session whose end fails stays owned and invalidated; the database's close() ends it again.
897
+ await session.close().catch(() => {});
898
+ }
899
+ };
900
+
782
901
  /**
783
902
  * Runs one model operation on the ordinary session boundary: the owned
784
903
  * session lease, the operation's client deadline and signal, and the mapping
785
904
  * of an expired driver deadline to the `timeout` persistence error. A write
786
905
  * that fails once started may still have applied, so it changes every open
787
- * read set that read its collection.
906
+ * read set that read its collection. A write of a database with write
907
+ * revisions runs in an owned transaction; outside one it starts its own.
788
908
  */
789
909
  export const runWithOrdinarySmartdataSession = async <TResult>(
790
910
  sessionArg: TSmartdataOrdinarySession | undefined,
@@ -796,6 +916,18 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
796
916
  ) => Promise<TResult>,
797
917
  ): Promise<TResult> => {
798
918
  optionsArg.signal?.throwIfAborted();
919
+ if (optionsArg.ordinaryWrite && needsSmartdataRevisionTransaction(sessionArg, ownerArg)) {
920
+ return runSmartdataRevisionTransaction(
921
+ sessionArg as SmartdataSession | undefined,
922
+ ownerArg,
923
+ optionsArg,
924
+ (transactionSessionArg) => runWithOrdinarySmartdataSession(transactionSessionArg, ownerArg, {
925
+ ordinaryWrite: true,
926
+ prepared: true,
927
+ ...(optionsArg.collectionName === undefined ? {} : { collectionName: optionsArg.collectionName }),
928
+ }, operationArg),
929
+ );
930
+ }
799
931
  const lease = leaseOrdinarySmartdataSession(sessionArg, ownerArg, optionsArg);
800
932
  let deadline: SmartdataOperationDeadline | undefined;
801
933
  let writeStarted = false;
@@ -833,6 +965,41 @@ const bufferTransactionWrite = (
833
965
  }
834
966
  };
835
967
 
968
+ /**
969
+ * Adds write-revision counters to the current attempt of an owned transaction.
970
+ * A write that settles after its attempt ended rolls back with that attempt.
971
+ */
972
+ const bumpTransactionRevisions = (
973
+ bindingArg: ISmartdataSessionBinding,
974
+ idsArg: Iterable<string>,
975
+ ): void => {
976
+ const attempt = transactionContext.getStore()?.attempt;
977
+ if (attempt === undefined || attempt !== bindingArg.transactionAttempt) return;
978
+ for (const id of idsArg) bindingArg.revisionBumps.add(id);
979
+ };
980
+
981
+ /**
982
+ * Reads, in the write's transaction, the write-revision partitions of the
983
+ * documents a write by selector is about to change, and returns whether it
984
+ * did; the write's report then states `previousPartitionsRead`. `false` when
985
+ * the database keeps no revisions or the write runs outside an owned
986
+ * transaction.
987
+ */
988
+ export const readPreviousWriteRevisions = async (
989
+ ownerArg: SmartdataDb,
990
+ collectionArg: plugins.mongodb.Collection<any>,
991
+ rawSessionArg: plugins.mongodb.ClientSession | undefined,
992
+ selectorArg: unknown,
993
+ deadlineArg: SmartdataOperationDeadline,
994
+ ): Promise<boolean> => {
995
+ if (!rawSessionArg || !isSmartdataWriteRevisionsActive(ownerArg)) return false;
996
+ const binding = bindingsByRawSession.get(rawSessionArg);
997
+ if (!binding?.transactionActive || binding.owner !== ownerArg) return false;
998
+ const ids = await readPreviousRevisionIds(ownerArg, collectionArg, rawSessionArg, selectorArg, deadlineArg);
999
+ bumpTransactionRevisions(binding, ids);
1000
+ return true;
1001
+ };
1002
+
836
1003
  /**
837
1004
  * Reports one acknowledged write to the owner's write observers and open read
838
1005
  * sets: at once, or, inside an owned transaction, after it committed. A
@@ -845,6 +1012,7 @@ export const reportSmartdataWrite = (
845
1012
  sessionArg: TSmartdataOrdinarySession | undefined,
846
1013
  writeArg: ISmartdataWrite,
847
1014
  ): void => {
1015
+ sessionArg ??= revisionTransactionSessionOf(ownerArg);
848
1016
  if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) {
849
1017
  // A raw driver transaction passed its lease while nobody observed or
850
1018
  // tracked, and whether it commits is never known here.
@@ -853,6 +1021,12 @@ export const reportSmartdataWrite = (
853
1021
  }
854
1022
  const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
855
1023
  const buffered = binding?.transactionActive === true;
1024
+ if (binding && buffered) {
1025
+ const field = writeRevisionsPartitionField(ownerArg);
1026
+ if (field !== undefined && isSmartdataWriteRevisionsActive(ownerArg)) {
1027
+ bumpTransactionRevisions(binding, writeRevisionIdsOfWrite(writeArg, field));
1028
+ }
1029
+ }
856
1030
  const changeReads = captureSmartdataReadChange(ownerArg, writeArg, buffered);
857
1031
  const deliver = captureSmartdataWrite(ownerArg, writeArg);
858
1032
  if (!changeReads && !deliver) return;
@@ -874,8 +1048,12 @@ export const reportSmartdataWriteFailure = (
874
1048
  sessionArg: TSmartdataOrdinarySession | undefined,
875
1049
  collectionNameArg: string,
876
1050
  ): void => {
1051
+ sessionArg ??= revisionTransactionSessionOf(ownerArg);
877
1052
  const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
878
1053
  const buffered = binding?.transactionActive === true;
1054
+ if (binding && buffered && isSmartdataWriteRevisionsActive(ownerArg)) {
1055
+ bumpTransactionRevisions(binding, [sharedRevisionId(collectionNameArg)]);
1056
+ }
879
1057
  const changeReads = captureSmartdataCollectionChange(ownerArg, collectionNameArg, buffered);
880
1058
  if (!changeReads) return;
881
1059
  if (binding && buffered) {
@@ -897,7 +1075,7 @@ export const reportSmartdataCollectionWrite = (
897
1075
  kindArg: TSmartdataWriteKind,
898
1076
  documentsArg: readonly object[] | null,
899
1077
  selectorArg?: unknown,
900
- readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update'>,
1078
+ readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update' | 'previousPartitionsRead'>,
901
1079
  ): void => {
902
1080
  const schema = collectionArg.getBoundModelSchema();
903
1081
  reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
@@ -922,41 +1100,49 @@ const identityFieldsOfSchema = (
922
1100
  /**
923
1101
  * Registers one read of a model collection with the open read sets of the
924
1102
  * current async context, right before it is issued; `undefined` when none is
925
- * open. See `observeSmartdataRead`.
1103
+ * open. See `observeSmartdataRead`. Settles once the write revisions the read
1104
+ * depends on were read, within `deadlineArg`; issue the read after it.
926
1105
  */
927
1106
  export const trackSmartdataRead = (
928
1107
  collectionArg: SmartdataCollection<any>,
929
1108
  sessionArg: TSmartdataOrdinarySession | undefined,
930
- specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
931
- ): ISmartdataReadRecorder | undefined => trackSmartdataModelRead(
1109
+ specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
1110
+ deadlineArg?: SmartdataOperationDeadline,
1111
+ ): Promise<ISmartdataReadRecorder | undefined> => trackSmartdataModelRead(
932
1112
  collectionArg.smartdataDb,
933
1113
  collectionArg.collectionName,
934
1114
  collectionArg.getBoundModelSchema(),
935
1115
  sessionArg,
936
1116
  specArg,
1117
+ deadlineArg,
937
1118
  );
938
1119
 
939
1120
  /** `trackSmartdataRead` for a read that names its model by schema rather than collection. */
940
- export const trackSmartdataModelRead = (
1121
+ export const trackSmartdataModelRead = async (
941
1122
  dbArg: SmartdataDb,
942
1123
  collectionNameArg: string,
943
1124
  schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
944
1125
  sessionArg: TSmartdataOrdinarySession | undefined,
945
- specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
946
- ): ISmartdataReadRecorder | undefined => {
1126
+ specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
1127
+ deadlineArg?: SmartdataOperationDeadline,
1128
+ ): Promise<ISmartdataReadRecorder | undefined> => {
947
1129
  if (!hasSmartdataReadScope()) return undefined;
948
1130
  let inTransaction: boolean;
949
1131
  try {
950
- inTransaction = isOrdinarySessionInTransaction(sessionArg);
1132
+ inTransaction = isOrdinarySessionInTransaction(sessionArg ?? revisionTransactionSessionOf(dbArg));
951
1133
  } catch {
952
1134
  // An ended session's read fails; a set must not count it as followed.
953
1135
  inTransaction = true;
954
1136
  }
955
- return observeSmartdataRead(dbArg, {
1137
+ const identityFields = identityFieldsOfSchema(schemaArg);
1138
+ const recorder = observeSmartdataRead(dbArg, {
956
1139
  ...specArg,
957
1140
  collection: collectionNameArg,
958
- identityFields: identityFieldsOfSchema(schemaArg),
1141
+ identityFields,
1142
+ orderingIdentityFields: schemaArg && identityFields ? getOrderingIdentityFields(schemaArg) : undefined,
959
1143
  }, inTransaction);
1144
+ if (recorder?.ready) await (deadlineArg ? deadlineArg.join(recorder.ready) : recorder.ready);
1145
+ return recorder;
960
1146
  };
961
1147
 
962
1148
  /**
@@ -3,6 +3,7 @@ import type { SmartdataDb } from './classes.db.js';
3
3
  import { convertFilterForMongoDb } from './classes.doc.js';
4
4
  import { SmartdataPersistenceError } from './classes.persistence.js';
5
5
  import { runCursorOperation } from './classes.cursoroperation.js';
6
+ import { SmartdataOperationDeadline } from './classes.operationdeadline.js';
6
7
  import {
7
8
  acquireSmartdataSession, isSmartdataSession, trackSmartdataModelRead, type SmartdataSession,
8
9
  } from './classes.session.js';
@@ -81,12 +82,14 @@ const readStoredDocuments = async (
81
82
  let page: { documents: plugins.mongodb.Document[]; hasMore: boolean };
82
83
  try {
83
84
  page = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
84
- const tracked = trackSmartdataModelRead(dbArg, schema.collectionName, schema, opts.session, {
85
+ const deadline = new SmartdataOperationDeadline(timeoutMS, opts.signal);
86
+ const tracked = await trackSmartdataModelRead(dbArg, schema.collectionName, schema, opts.session, {
85
87
  kind: 'documents', filter, sort: { _id: 1 }, matches: skip > 0, limited: true,
86
- });
88
+ }, deadline);
87
89
  const cursor = mongoDbArg.collection(schema.collectionName).find(filter, {
88
90
  sort: { _id: 1 }, skip, limit: limit + 1, batchSize: 1,
89
- timeoutMS, timeoutMode: 'cursorLifetime', signal: opts.signal, session: lease?.rawSession,
91
+ timeoutMS: deadline.remainingTimeoutMS(), timeoutMode: 'cursorLifetime', signal: opts.signal,
92
+ session: lease?.rawSession,
90
93
  });
91
94
  return runCursorOperation(cursor, async (cursorArg) => {
92
95
  const documents: plugins.mongodb.Document[] = [];
@@ -62,6 +62,11 @@ export interface ISmartdataWrite {
62
62
  update?: unknown;
63
63
  /** The model's identity fields, by which a selector names a document without its `_id`. */
64
64
  identityFields?: readonly string[];
65
+ /**
66
+ * The write-revision partitions of the documents the selector matched were
67
+ * read in the write's transaction before it ran.
68
+ */
69
+ previousPartitionsRead?: true;
65
70
  }
66
71
 
67
72
  interface IWriteObserver {