@lossless.org/client 1.20.2 → 1.21.1

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