@lossless.org/client 1.20.2 → 1.21.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 (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.js +3 -3
  3. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +6 -3
  4. package/dist_ts/nosqldb/classes.atomicupdate.js +4 -4
  5. package/dist_ts/nosqldb/classes.bootstraprecord.js +10 -5
  6. package/dist_ts/nosqldb/classes.collection.js +53 -16
  7. package/dist_ts/nosqldb/classes.cursor.d.ts +4 -1
  8. package/dist_ts/nosqldb/classes.cursor.js +14 -3
  9. package/dist_ts/nosqldb/classes.db.d.ts +22 -0
  10. package/dist_ts/nosqldb/classes.db.js +25 -1
  11. package/dist_ts/nosqldb/classes.doc.js +35 -5
  12. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +6 -0
  13. package/dist_ts/nosqldb/classes.exactpersistence.js +70 -13
  14. package/dist_ts/nosqldb/classes.readtracker.d.ts +87 -0
  15. package/dist_ts/nosqldb/classes.readtracker.js +876 -0
  16. package/dist_ts/nosqldb/classes.session.d.ts +30 -7
  17. package/dist_ts/nosqldb/classes.session.js +116 -26
  18. package/dist_ts/nosqldb/classes.storedinspection.js +12 -4
  19. package/dist_ts/nosqldb/classes.testdatabase.js +16 -9
  20. package/dist_ts/nosqldb/classes.writeobserver.d.ts +11 -0
  21. package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
  22. package/package.json +1 -1
  23. package/readme.md +1 -0
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/nosqldb/classes.atomicdelete.ts +16 -2
  26. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +5 -0
  27. package/ts/nosqldb/classes.atomicupdate.ts +10 -1
  28. package/ts/nosqldb/classes.bootstraprecord.ts +7 -4
  29. package/ts/nosqldb/classes.collection.ts +56 -12
  30. package/ts/nosqldb/classes.cursor.ts +14 -1
  31. package/ts/nosqldb/classes.db.ts +32 -0
  32. package/ts/nosqldb/classes.doc.ts +32 -2
  33. package/ts/nosqldb/classes.exactpersistence.ts +93 -19
  34. package/ts/nosqldb/classes.readtracker.ts +1028 -0
  35. package/ts/nosqldb/classes.session.ts +163 -30
  36. package/ts/nosqldb/classes.storedinspection.ts +15 -3
  37. package/ts/nosqldb/classes.testdatabase.ts +14 -8
  38. package/ts/nosqldb/classes.writeobserver.ts +11 -0
@@ -22,6 +22,16 @@ import {
22
22
  type ISmartdataWrite,
23
23
  type TSmartdataWriteKind,
24
24
  } from './classes.writeobserver.js';
25
+ import {
26
+ captureSmartdataCollectionChange,
27
+ captureSmartdataReadChange,
28
+ hasSmartdataReadScope,
29
+ hasSmartdataReadSets,
30
+ observeSmartdataRead,
31
+ runSmartdataWrite,
32
+ type ISmartdataReadRecorder,
33
+ type ISmartdataReadSpec,
34
+ } from './classes.readtracker.js';
25
35
 
26
36
  interface ISmartdataSessionBinding {
27
37
  session: SmartdataSession;
@@ -43,14 +53,28 @@ interface ISmartdataSessionBinding {
43
53
  transactionCommitReached: boolean;
44
54
  /** Set when the session ended while a transaction ran, stating how far the transaction had got. */
45
55
  transactionEnded?: TSmartdataTransactionEnd;
46
- /** The write reports of the current attempt, delivered once the transaction committed. */
47
- transactionWrites: Array<() => void>;
56
+ /**
57
+ * The writes of the current attempt: their reports, delivered once the
58
+ * transaction committed, and their changes to open read sets, also applied
59
+ * when the commit outcome is unknown.
60
+ */
61
+ transactionWrites: ISmartdataTransactionWrite[];
48
62
  operationSettled?: Promise<void>;
49
63
  resolveOperationSettled?: () => void;
50
64
  invalidated: boolean;
51
65
  closing?: Promise<void>;
52
66
  }
53
67
 
68
+ interface ISmartdataTransactionWrite {
69
+ deliver?: () => void;
70
+ changeReads?: () => void;
71
+ }
72
+
73
+ /** A transaction whose commit outcome is unknown may have applied its writes. */
74
+ const changeReadsOfTransaction = (writesArg: readonly ISmartdataTransactionWrite[]): void => {
75
+ for (const write of writesArg) write.changeReads?.();
76
+ };
77
+
54
78
  /** How far a transaction had got when its session ended outside the callback's control. */
55
79
  type TSmartdataTransactionEnd = 'before_commit' | 'during_commit';
56
80
 
@@ -445,7 +469,7 @@ export class SmartdataSession {
445
469
  };
446
470
  let result: TResult | undefined;
447
471
  let transactionEnded: TSmartdataTransactionEnd | undefined;
448
- let committedWrites: Array<() => void> = [];
472
+ let committedWrites: ISmartdataTransactionWrite[] = [];
449
473
  try {
450
474
  result = timeoutMS === undefined
451
475
  ? await binding.rawSession.withTransaction(runAttempt)
@@ -454,6 +478,7 @@ export class SmartdataSession {
454
478
  committedWrites = binding.transactionWrites;
455
479
  } catch (errorArg) {
456
480
  const { error, outcome } = settleTransactionRejection(errorArg);
481
+ if (outcome === 'unknown') changeReadsOfTransaction(binding.transactionWrites);
457
482
  throw rejectTransaction(error, outcome);
458
483
  } finally {
459
484
  binding.transactionActive = false;
@@ -466,13 +491,13 @@ export class SmartdataSession {
466
491
  binding.transactionWrites = [];
467
492
  }
468
493
  if (transactionEnded) {
469
- throw rejectTransaction(
470
- createTransactionSessionEndedError(transactionEnded),
471
- commitOutcomeOfTransactionEnd(transactionEnded),
472
- );
494
+ const outcome = commitOutcomeOfTransactionEnd(transactionEnded);
495
+ if (outcome === 'unknown') changeReadsOfTransaction(committedWrites);
496
+ throw rejectTransaction(createTransactionSessionEndedError(transactionEnded), outcome);
473
497
  }
474
- for (const deliverWrite of committedWrites) {
475
- deliverWrite();
498
+ changeReadsOfTransaction(committedWrites);
499
+ for (const write of committedWrites) {
500
+ write.deliver?.();
476
501
  }
477
502
  return result;
478
503
  }
@@ -676,12 +701,14 @@ export const leaseOrdinarySmartdataSession = (
676
701
  if (
677
702
  optionsArg.ordinaryWrite
678
703
  && sessionArg?.inTransaction()
679
- && hasSmartdataWriteObservers(ownerArg)
704
+ && (hasSmartdataWriteObservers(ownerArg) || hasSmartdataReadSets(ownerArg))
680
705
  ) {
681
706
  // Only an owned transaction tells SmartData whether it committed.
682
707
  throw new SmartdataPersistenceError(
683
708
  'unsupported_operation',
684
- 'Writes of an observed database run in transactions of db.createSession(), not raw driver sessions.',
709
+ hasSmartdataWriteObservers(ownerArg)
710
+ ? 'Writes of an observed database run in transactions of db.createSession(), not raw driver sessions.'
711
+ : 'Writes of a database with open read sets run in transactions of db.createSession(), not raw driver sessions.',
685
712
  );
686
713
  }
687
714
  return { rawSession: sessionArg, release: () => {} };
@@ -755,7 +782,9 @@ export const settleSmartdataOperationError = (
755
782
  /**
756
783
  * Runs one model operation on the ordinary session boundary: the owned
757
784
  * session lease, the operation's client deadline and signal, and the mapping
758
- * of an expired driver deadline to the `timeout` persistence error.
785
+ * of an expired driver deadline to the `timeout` persistence error. A write
786
+ * that fails once started may still have applied, so it changes every open
787
+ * read set that read its collection.
759
788
  */
760
789
  export const runWithOrdinarySmartdataSession = async <TResult>(
761
790
  sessionArg: TSmartdataOrdinarySession | undefined,
@@ -769,10 +798,19 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
769
798
  optionsArg.signal?.throwIfAborted();
770
799
  const lease = leaseOrdinarySmartdataSession(sessionArg, ownerArg, optionsArg);
771
800
  let deadline: SmartdataOperationDeadline | undefined;
801
+ let writeStarted = false;
772
802
  try {
773
803
  deadline = resolveSmartdataOperationDeadline(sessionArg, optionsArg);
774
- return await operationArg(lease.rawSession, deadline);
804
+ if (!optionsArg.ordinaryWrite) {
805
+ return await operationArg(lease.rawSession, deadline);
806
+ }
807
+ const writeDeadline = deadline;
808
+ writeStarted = true;
809
+ return await runSmartdataWrite(ownerArg, () => operationArg(lease.rawSession, writeDeadline));
775
810
  } catch (errorArg) {
811
+ if (writeStarted && optionsArg.collectionName) {
812
+ reportSmartdataWriteFailure(ownerArg, sessionArg, optionsArg.collectionName);
813
+ }
776
814
  throw deadline
777
815
  ? settleSmartdataOperationError(sessionArg, deadline, errorArg)
778
816
  : normalizeDeadlineError(errorArg);
@@ -782,50 +820,145 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
782
820
  };
783
821
 
784
822
  /**
785
- * Reports one acknowledged write to the owner's write observers: at once, or,
786
- * inside an owned transaction, after it committed. A transaction that rolls
787
- * back, or whose commit outcome is unknown, reports nothing, and a retried
788
- * attempt reports only its own writes.
823
+ * Buffers one write of an owned transaction's current attempt. A write that
824
+ * settles after its attempt ended rolls back with that attempt.
825
+ */
826
+ const bufferTransactionWrite = (
827
+ bindingArg: ISmartdataSessionBinding,
828
+ writeArg: ISmartdataTransactionWrite,
829
+ ): void => {
830
+ const attempt = transactionContext.getStore()?.attempt;
831
+ if (attempt !== undefined && attempt === bindingArg.transactionAttempt) {
832
+ bindingArg.transactionWrites.push(writeArg);
833
+ }
834
+ };
835
+
836
+ /**
837
+ * Reports one acknowledged write to the owner's write observers and open read
838
+ * sets: at once, or, inside an owned transaction, after it committed. A
839
+ * transaction that rolls back reports nothing, one whose commit outcome is
840
+ * unknown changes the read sets only, and a retried attempt reports only its
841
+ * own writes. Read sets change before observers hear of the write.
789
842
  */
790
843
  export const reportSmartdataWrite = (
791
844
  ownerArg: SmartdataDb,
792
845
  sessionArg: TSmartdataOrdinarySession | undefined,
793
846
  writeArg: ISmartdataWrite,
794
847
  ): void => {
795
- // A raw driver transaction passed its lease while nobody observed, and
796
- // whether it commits is never known here.
797
- if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) return;
848
+ if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) {
849
+ // A raw driver transaction passed its lease while nobody observed or
850
+ // tracked, and whether it commits is never known here.
851
+ captureSmartdataCollectionChange(ownerArg, writeArg.collection, false)?.();
852
+ return;
853
+ }
854
+ const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
855
+ const buffered = binding?.transactionActive === true;
856
+ const changeReads = captureSmartdataReadChange(ownerArg, writeArg, buffered);
798
857
  const deliver = captureSmartdataWrite(ownerArg, writeArg);
799
- if (!deliver) return;
858
+ if (!changeReads && !deliver) return;
859
+ if (binding && buffered) {
860
+ bufferTransactionWrite(binding, { deliver, changeReads });
861
+ return;
862
+ }
863
+ changeReads?.();
864
+ deliver?.();
865
+ };
866
+
867
+ /**
868
+ * Reports a write to the collection that failed after it was sent: it may
869
+ * still have applied, so every open read set that read the collection
870
+ * changes, inside an owned transaction once it commits.
871
+ */
872
+ export const reportSmartdataWriteFailure = (
873
+ ownerArg: SmartdataDb,
874
+ sessionArg: TSmartdataOrdinarySession | undefined,
875
+ collectionNameArg: string,
876
+ ): void => {
800
877
  const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
801
- if (binding?.transactionActive) {
802
- // A write that settles after its attempt ended rolls back with that attempt.
803
- const attempt = transactionContext.getStore()?.attempt;
804
- if (attempt !== undefined && attempt === binding.transactionAttempt) {
805
- binding.transactionWrites.push(deliver);
806
- }
878
+ const buffered = binding?.transactionActive === true;
879
+ const changeReads = captureSmartdataCollectionChange(ownerArg, collectionNameArg, buffered);
880
+ if (!changeReads) return;
881
+ if (binding && buffered) {
882
+ bufferTransactionWrite(binding, { changeReads });
807
883
  return;
808
884
  }
809
- deliver();
885
+ changeReads();
810
886
  };
811
887
 
812
- /** Reports one acknowledged write of a model collection; see `reportSmartdataWrite`. */
888
+ /**
889
+ * Reports one acknowledged write of a model collection; see
890
+ * `reportSmartdataWrite`. `readImagesArg` states which document states the
891
+ * documents are and the update applied; without it, read sets count the
892
+ * write as changing every read of the collection.
893
+ */
813
894
  export const reportSmartdataCollectionWrite = (
814
895
  collectionArg: SmartdataCollection<any>,
815
896
  sessionArg: TSmartdataOrdinarySession | undefined,
816
897
  kindArg: TSmartdataWriteKind,
817
898
  documentsArg: readonly object[] | null,
818
899
  selectorArg?: unknown,
900
+ readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update'>,
819
901
  ): void => {
902
+ const schema = collectionArg.getBoundModelSchema();
820
903
  reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
821
904
  collection: collectionArg.collectionName,
822
905
  kind: kindArg,
823
906
  documents: documentsArg,
824
907
  selector: selectorArg,
825
- identityDocumentIdField: collectionArg.getBoundModelSchema()?.identityAsDocumentId,
908
+ identityDocumentIdField: schema?.identityAsDocumentId,
909
+ identityFields: identityFieldsOfSchema(schema),
910
+ ...readImagesArg,
826
911
  });
827
912
  };
828
913
 
914
+ /** The identity fields a read or write names a document by besides `_id`. */
915
+ const identityFieldsOfSchema = (
916
+ schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
917
+ ): readonly string[] | undefined =>
918
+ schemaArg && !schemaArg.identityAsDocumentId && schemaArg.identityFields.length > 0
919
+ ? schemaArg.identityFields
920
+ : undefined;
921
+
922
+ /**
923
+ * Registers one read of a model collection with the open read sets of the
924
+ * current async context, right before it is issued; `undefined` when none is
925
+ * open. See `observeSmartdataRead`.
926
+ */
927
+ export const trackSmartdataRead = (
928
+ collectionArg: SmartdataCollection<any>,
929
+ sessionArg: TSmartdataOrdinarySession | undefined,
930
+ specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
931
+ ): ISmartdataReadRecorder | undefined => trackSmartdataModelRead(
932
+ collectionArg.smartdataDb,
933
+ collectionArg.collectionName,
934
+ collectionArg.getBoundModelSchema(),
935
+ sessionArg,
936
+ specArg,
937
+ );
938
+
939
+ /** `trackSmartdataRead` for a read that names its model by schema rather than collection. */
940
+ export const trackSmartdataModelRead = (
941
+ dbArg: SmartdataDb,
942
+ collectionNameArg: string,
943
+ schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
944
+ sessionArg: TSmartdataOrdinarySession | undefined,
945
+ specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
946
+ ): ISmartdataReadRecorder | undefined => {
947
+ if (!hasSmartdataReadScope()) return undefined;
948
+ let inTransaction: boolean;
949
+ try {
950
+ inTransaction = isOrdinarySessionInTransaction(sessionArg);
951
+ } catch {
952
+ // An ended session's read fails; a set must not count it as followed.
953
+ inTransaction = true;
954
+ }
955
+ return observeSmartdataRead(dbArg, {
956
+ ...specArg,
957
+ collection: collectionNameArg,
958
+ identityFields: identityFieldsOfSchema(schemaArg),
959
+ }, inTransaction);
960
+ };
961
+
829
962
  /**
830
963
  * Ends every session of the owner. `endOptionsArg.timeoutMS` is the driver's
831
964
  * deadline for aborting a transaction a session still holds.
@@ -3,7 +3,9 @@ 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 { acquireSmartdataSession, isSmartdataSession, type SmartdataSession } from './classes.session.js';
6
+ import {
7
+ acquireSmartdataSession, isSmartdataSession, trackSmartdataModelRead, type SmartdataSession,
8
+ } from './classes.session.js';
7
9
  import {
8
10
  collectionTopologyDbInspectionSymbol, collectionModelSchemaResolverSymbol,
9
11
  } from './classes.collectiontopology.js';
@@ -79,6 +81,9 @@ const readStoredDocuments = async (
79
81
  let page: { documents: plugins.mongodb.Document[]; hasMore: boolean };
80
82
  try {
81
83
  page = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
84
+ const tracked = trackSmartdataModelRead(dbArg, schema.collectionName, schema, opts.session, {
85
+ kind: 'documents', filter, sort: { _id: 1 }, matches: skip > 0, limited: true,
86
+ });
82
87
  const cursor = mongoDbArg.collection(schema.collectionName).find(filter, {
83
88
  sort: { _id: 1 }, skip, limit: limit + 1, batchSize: 1,
84
89
  timeoutMS, timeoutMode: 'cursorLifetime', signal: opts.signal, session: lease?.rawSession,
@@ -88,8 +93,15 @@ const readStoredDocuments = async (
88
93
  let bytes = 0;
89
94
  while (true) {
90
95
  const next = await cursorArg.next();
91
- if (next === null) return { documents, hasMore: false };
92
- if (documents.length === limit) return { documents, hasMore: true };
96
+ if (next === null) {
97
+ tracked?.settle();
98
+ return { documents, hasMore: false };
99
+ }
100
+ tracked?.record(next);
101
+ if (documents.length === limit) {
102
+ tracked?.settle();
103
+ return { documents, hasMore: true };
104
+ }
93
105
  bytes += plugins.mongodb.BSON.calculateObjectSize(next);
94
106
  if (bytes > maxBytes) throw new SmartdataPersistenceError('unsupported_operation',
95
107
  'Stored inspection exceeds its byte budget.');
@@ -3,6 +3,7 @@ import { snapshotBoundedBsonDocument } from './classes.bsonsnapshot.js';
3
3
  import { SmartdataDb } from './classes.db.js';
4
4
  import { SmartDataDbDoc } from './classes.doc.js';
5
5
  import { SmartdataPersistenceError } from './classes.persistence.js';
6
+ import { captureSmartdataCollectionChange } from './classes.readtracker.js';
6
7
  import { assertDataOptions, SmartdataOperationBudget } from './classes.operationbudget.js';
7
8
 
8
9
  /** A loopback TCP engine endpoint. */
@@ -255,14 +256,19 @@ export class SmartdataTestDatabase {
255
256
  await modelArg.prepareCollection({ mode: 'ensureCompatible', timeoutMs: budget.remainingMs(), signal: budget.signal });
256
257
  this.assertOwnedBinding();
257
258
  const raw = this.db.mongoDb.collection(expected.collectionName);
258
- if (replacementArg) {
259
- const result = await raw.replaceOne({ _id: document._id }, document,
260
- { upsert: false, ...budget.operationOptions() });
261
- if (!result.acknowledged || result.matchedCount !== 1) throw new SmartdataPersistenceError(
262
- 'unsupported_operation', 'Exact fault replacement requires one existing document.');
263
- } else {
264
- const result = await raw.insertOne(document, budget.operationOptions());
265
- if (!result.acknowledged) throw new SmartdataPersistenceError('unsupported_operation', 'Fault insertion was not acknowledged.');
259
+ try {
260
+ if (replacementArg) {
261
+ const result = await raw.replaceOne({ _id: document._id }, document,
262
+ { upsert: false, ...budget.operationOptions() });
263
+ if (!result.acknowledged || result.matchedCount !== 1) throw new SmartdataPersistenceError(
264
+ 'unsupported_operation', 'Exact fault replacement requires one existing document.');
265
+ } else {
266
+ const result = await raw.insertOne(document, budget.operationOptions());
267
+ if (!result.acknowledged) throw new SmartdataPersistenceError('unsupported_operation', 'Fault insertion was not acknowledged.');
268
+ }
269
+ } finally {
270
+ // A fault writes behind the models, so every open read set that read the collection changes.
271
+ captureSmartdataCollectionChange(this.db, expected.collectionName, false)?.();
266
272
  }
267
273
  budget.check();
268
274
  })();
@@ -51,6 +51,17 @@ export interface ISmartdataWrite {
51
51
  selector?: unknown;
52
52
  /** The model's `identityAsDocumentId` field, whose value a selector carries as `_id`. */
53
53
  identityDocumentIdField?: string;
54
+ /**
55
+ * Which stored states `documents` are, for read sets: the whole document
56
+ * after the write (for a replacement, the last one), before it, both
57
+ * (before, then after), or neither. A write without it changes every read
58
+ * of its collection.
59
+ */
60
+ images?: 'after' | 'before' | 'beforeAndAfter' | 'none';
61
+ /** The update document a write by selector applied. */
62
+ update?: unknown;
63
+ /** The model's identity fields, by which a selector names a document without its `_id`. */
64
+ identityFields?: readonly string[];
54
65
  }
55
66
 
56
67
  interface IWriteObserver {