@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
@@ -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);
@@ -67,6 +110,11 @@ export interface ISmartdataReadRecorder {
67
110
  record(documentArg: unknown): void;
68
111
  /** The read returned everything it will return. */
69
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>;
70
118
  }
71
119
 
72
120
  // Values an image or a stored path can hold beside real values.
@@ -110,6 +158,15 @@ interface IReadSetState {
110
158
  /** The callback returned, so its read collections have been checked. */
111
159
  finished: boolean;
112
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;
113
170
  }
114
171
 
115
172
  /** What one write did to one document, as far as it is known. */
@@ -830,6 +887,7 @@ export const trackSmartdataReads = async <TResult>(
830
887
  }
831
888
  const reads = new SmartdataReadSet();
832
889
  const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
890
+ if (isSmartdataWriteRevisionsActive(dbArg)) state.revisions = { snapshots: new Map(), lost: false };
833
891
  readSetStates.set(reads, state);
834
892
  let states = readSetsByDb.get(dbArg);
835
893
  if (!states) {
@@ -883,6 +941,45 @@ const limitFollowsQuery = (specArg: ISmartdataReadSpec): boolean => {
883
941
  isPlainRecord(sort) && Object.hasOwn(sort, fieldArg));
884
942
  };
885
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);
981
+ };
982
+
886
983
  /**
887
984
  * @internal Registers one read with every open read set of the current async
888
985
  * context, before the read is issued. A read inside a transaction sees writes
@@ -948,6 +1045,7 @@ export const observeSmartdataRead = (
948
1045
  reads.push(observation);
949
1046
  }
950
1047
  return {
1048
+ ready: snapshotWriteRevisions(dbArg, specArg, followed),
951
1049
  record: (documentArg) => {
952
1050
  if (observation.settled) return;
953
1051
  if (!isPlainRecord(documentArg)) {
@@ -1029,3 +1127,44 @@ export const captureSmartdataCollectionChange = (
1029
1127
  if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
1030
1128
  return () => applyChanges(dbArg, collectionArg, [{ kind: 'unknown', touched: 'all', startedAt: 0 }]);
1031
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
+ };
@@ -30,9 +30,17 @@ import {
30
30
  hasSmartdataReadSets,
31
31
  observeSmartdataRead,
32
32
  runSmartdataWrite,
33
+ writeRevisionIdsOfWrite,
33
34
  type ISmartdataReadRecorder,
34
35
  type ISmartdataReadSpec,
35
36
  } from './classes.readtracker.js';
37
+ import {
38
+ flushSmartdataWriteRevisions,
39
+ isSmartdataWriteRevisionsActive,
40
+ readPreviousRevisionIds,
41
+ sharedRevisionId,
42
+ writeRevisionsPartitionField,
43
+ } from './classes.writerevisions.js';
36
44
 
37
45
  interface ISmartdataSessionBinding {
38
46
  session: SmartdataSession;
@@ -60,6 +68,8 @@ interface ISmartdataSessionBinding {
60
68
  * when the commit outcome is unknown.
61
69
  */
62
70
  transactionWrites: ISmartdataTransactionWrite[];
71
+ /** The write-revision counters the current attempt's writes bump right before its commit. */
72
+ revisionBumps: Set<string>;
63
73
  operationSettled?: Promise<void>;
64
74
  resolveOperationSettled?: () => void;
65
75
  invalidated: boolean;
@@ -136,6 +146,8 @@ const createTransactionSessionEndedError = (
136
146
  );
137
147
 
138
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>();
139
151
  const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
140
152
  const smartdataSessionInvalidationAggregateBrand = Symbol(
141
153
  'smartdataSessionInvalidationAggregate',
@@ -156,6 +168,11 @@ interface ISmartdataSessionAcquireOptions {
156
168
  interface IOrdinarySessionOperationOptions {
157
169
  ordinaryWrite?: boolean;
158
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>;
159
176
  /** Names the collection whose preparation a refusal must ask for. */
160
177
  collectionName?: string;
161
178
  /** The caller's validated client deadline for the call's database work. */
@@ -186,6 +203,16 @@ const transactionContext = new plugins.nodeAsyncHooks.AsyncLocalStorage<
186
203
  ITransactionContext
187
204
  >();
188
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
+
189
216
  const resolveBinding = (
190
217
  sessionArg: SmartdataSession,
191
218
  ownerArg?: SmartdataDb,
@@ -342,6 +369,7 @@ export class SmartdataSession {
342
369
  binding.transactionFailure = undefined;
343
370
  binding.transactionCommitReached = false;
344
371
  binding.transactionWrites = [];
372
+ binding.revisionBumps = new Set();
345
373
  callbackError = undefined;
346
374
  attemptInterruption = undefined;
347
375
  try {
@@ -393,6 +421,16 @@ export class SmartdataSession {
393
421
  }
394
422
  // An abort that arrived while the callback ran rolls the attempt back.
395
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
+ }
396
434
  // The driver commits next; a deadline that expires or a session that
397
435
  // ends from here on leaves the commit outcome unknown.
398
436
  binding.transactionCommitReached = true;
@@ -490,6 +528,7 @@ export class SmartdataSession {
490
528
  binding.transactionCommitReached = false;
491
529
  binding.transactionEnded = undefined;
492
530
  binding.transactionWrites = [];
531
+ binding.revisionBumps = new Set();
493
532
  }
494
533
  if (transactionEnded) {
495
534
  const outcome = commitOutcomeOfTransactionEnd(transactionEnded);
@@ -538,9 +577,11 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
538
577
  transactionUsesOrdinaryWrite: false,
539
578
  transactionCommitReached: false,
540
579
  transactionWrites: [],
580
+ revisionBumps: new Set(),
541
581
  invalidated: false,
542
582
  };
543
583
  bindings.set(session, binding);
584
+ bindingsByRawSession.set(binding.rawSession, binding);
544
585
  let ownerBindings = bindingsByOwner.get(ownerArg);
545
586
  if (!ownerBindings) {
546
587
  ownerBindings = new Set();
@@ -780,12 +821,90 @@ export const settleSmartdataOperationError = (
780
821
  return error;
781
822
  };
782
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
+
783
901
  /**
784
902
  * Runs one model operation on the ordinary session boundary: the owned
785
903
  * session lease, the operation's client deadline and signal, and the mapping
786
904
  * of an expired driver deadline to the `timeout` persistence error. A write
787
905
  * that fails once started may still have applied, so it changes every open
788
- * 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.
789
908
  */
790
909
  export const runWithOrdinarySmartdataSession = async <TResult>(
791
910
  sessionArg: TSmartdataOrdinarySession | undefined,
@@ -797,6 +916,18 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
797
916
  ) => Promise<TResult>,
798
917
  ): Promise<TResult> => {
799
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
+ }
800
931
  const lease = leaseOrdinarySmartdataSession(sessionArg, ownerArg, optionsArg);
801
932
  let deadline: SmartdataOperationDeadline | undefined;
802
933
  let writeStarted = false;
@@ -834,6 +965,41 @@ const bufferTransactionWrite = (
834
965
  }
835
966
  };
836
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
+
837
1003
  /**
838
1004
  * Reports one acknowledged write to the owner's write observers and open read
839
1005
  * sets: at once, or, inside an owned transaction, after it committed. A
@@ -846,6 +1012,7 @@ export const reportSmartdataWrite = (
846
1012
  sessionArg: TSmartdataOrdinarySession | undefined,
847
1013
  writeArg: ISmartdataWrite,
848
1014
  ): void => {
1015
+ sessionArg ??= revisionTransactionSessionOf(ownerArg);
849
1016
  if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) {
850
1017
  // A raw driver transaction passed its lease while nobody observed or
851
1018
  // tracked, and whether it commits is never known here.
@@ -854,6 +1021,12 @@ export const reportSmartdataWrite = (
854
1021
  }
855
1022
  const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
856
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
+ }
857
1030
  const changeReads = captureSmartdataReadChange(ownerArg, writeArg, buffered);
858
1031
  const deliver = captureSmartdataWrite(ownerArg, writeArg);
859
1032
  if (!changeReads && !deliver) return;
@@ -875,8 +1048,12 @@ export const reportSmartdataWriteFailure = (
875
1048
  sessionArg: TSmartdataOrdinarySession | undefined,
876
1049
  collectionNameArg: string,
877
1050
  ): void => {
1051
+ sessionArg ??= revisionTransactionSessionOf(ownerArg);
878
1052
  const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
879
1053
  const buffered = binding?.transactionActive === true;
1054
+ if (binding && buffered && isSmartdataWriteRevisionsActive(ownerArg)) {
1055
+ bumpTransactionRevisions(binding, [sharedRevisionId(collectionNameArg)]);
1056
+ }
880
1057
  const changeReads = captureSmartdataCollectionChange(ownerArg, collectionNameArg, buffered);
881
1058
  if (!changeReads) return;
882
1059
  if (binding && buffered) {
@@ -898,7 +1075,7 @@ export const reportSmartdataCollectionWrite = (
898
1075
  kindArg: TSmartdataWriteKind,
899
1076
  documentsArg: readonly object[] | null,
900
1077
  selectorArg?: unknown,
901
- readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update'>,
1078
+ readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update' | 'previousPartitionsRead'>,
902
1079
  ): void => {
903
1080
  const schema = collectionArg.getBoundModelSchema();
904
1081
  reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
@@ -923,43 +1100,49 @@ const identityFieldsOfSchema = (
923
1100
  /**
924
1101
  * Registers one read of a model collection with the open read sets of the
925
1102
  * current async context, right before it is issued; `undefined` when none is
926
- * 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.
927
1105
  */
928
1106
  export const trackSmartdataRead = (
929
1107
  collectionArg: SmartdataCollection<any>,
930
1108
  sessionArg: TSmartdataOrdinarySession | undefined,
931
1109
  specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
932
- ): ISmartdataReadRecorder | undefined => trackSmartdataModelRead(
1110
+ deadlineArg?: SmartdataOperationDeadline,
1111
+ ): Promise<ISmartdataReadRecorder | undefined> => trackSmartdataModelRead(
933
1112
  collectionArg.smartdataDb,
934
1113
  collectionArg.collectionName,
935
1114
  collectionArg.getBoundModelSchema(),
936
1115
  sessionArg,
937
1116
  specArg,
1117
+ deadlineArg,
938
1118
  );
939
1119
 
940
1120
  /** `trackSmartdataRead` for a read that names its model by schema rather than collection. */
941
- export const trackSmartdataModelRead = (
1121
+ export const trackSmartdataModelRead = async (
942
1122
  dbArg: SmartdataDb,
943
1123
  collectionNameArg: string,
944
1124
  schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
945
1125
  sessionArg: TSmartdataOrdinarySession | undefined,
946
1126
  specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
947
- ): ISmartdataReadRecorder | undefined => {
1127
+ deadlineArg?: SmartdataOperationDeadline,
1128
+ ): Promise<ISmartdataReadRecorder | undefined> => {
948
1129
  if (!hasSmartdataReadScope()) return undefined;
949
1130
  let inTransaction: boolean;
950
1131
  try {
951
- inTransaction = isOrdinarySessionInTransaction(sessionArg);
1132
+ inTransaction = isOrdinarySessionInTransaction(sessionArg ?? revisionTransactionSessionOf(dbArg));
952
1133
  } catch {
953
1134
  // An ended session's read fails; a set must not count it as followed.
954
1135
  inTransaction = true;
955
1136
  }
956
1137
  const identityFields = identityFieldsOfSchema(schemaArg);
957
- return observeSmartdataRead(dbArg, {
1138
+ const recorder = observeSmartdataRead(dbArg, {
958
1139
  ...specArg,
959
1140
  collection: collectionNameArg,
960
1141
  identityFields,
961
1142
  orderingIdentityFields: schemaArg && identityFields ? getOrderingIdentityFields(schemaArg) : undefined,
962
1143
  }, inTransaction);
1144
+ if (recorder?.ready) await (deadlineArg ? deadlineArg.join(recorder.ready) : recorder.ready);
1145
+ return recorder;
963
1146
  };
964
1147
 
965
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 {