@lossless.org/client 1.19.0 → 1.20.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 (31) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.js +12 -4
  3. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +11 -2
  4. package/dist_ts/nosqldb/classes.atomicupdate.d.ts +2 -0
  5. package/dist_ts/nosqldb/classes.atomicupdate.js +32 -5
  6. package/dist_ts/nosqldb/classes.bootstraprecord.js +7 -2
  7. package/dist_ts/nosqldb/classes.collection.js +52 -7
  8. package/dist_ts/nosqldb/classes.db.d.ts +19 -0
  9. package/dist_ts/nosqldb/classes.db.js +21 -1
  10. package/dist_ts/nosqldb/classes.doc.js +2 -2
  11. package/dist_ts/nosqldb/classes.exactpersistence.js +17 -2
  12. package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
  13. package/dist_ts/nosqldb/classes.persistence.js +16 -1
  14. package/dist_ts/nosqldb/classes.session.d.ts +11 -0
  15. package/dist_ts/nosqldb/classes.session.js +51 -1
  16. package/dist_ts/nosqldb/classes.writeobserver.d.ts +57 -0
  17. package/dist_ts/nosqldb/classes.writeobserver.js +139 -0
  18. package/package.json +3 -3
  19. package/readme.md +2 -1
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  22. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +22 -0
  23. package/ts/nosqldb/classes.atomicupdate.ts +53 -3
  24. package/ts/nosqldb/classes.bootstraprecord.ts +7 -1
  25. package/ts/nosqldb/classes.collection.ts +63 -4
  26. package/ts/nosqldb/classes.db.ts +31 -0
  27. package/ts/nosqldb/classes.doc.ts +1 -1
  28. package/ts/nosqldb/classes.exactpersistence.ts +24 -0
  29. package/ts/nosqldb/classes.persistence.ts +16 -0
  30. package/ts/nosqldb/classes.session.ts +73 -0
  31. package/ts/nosqldb/classes.writeobserver.ts +224 -0
@@ -1,7 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { SmartdataCollection } from './classes.collection.js';
3
- import { normalizeOrdinaryPersistenceError } from './classes.persistence.js';
4
3
  import {
4
+ getMongoBulkWriteErrorIndexes,
5
+ normalizeOrdinaryPersistenceError,
6
+ } from './classes.persistence.js';
7
+ import {
8
+ reportSmartdataCollectionWrite,
5
9
  runWithOrdinarySmartdataSession,
6
10
  type TSmartdataOrdinarySession,
7
11
  } from './classes.session.js';
@@ -21,6 +25,8 @@ export const executeAtomicUpdate = async <T>(
21
25
  upsert?: boolean;
22
26
  session?: TSmartdataOrdinarySession;
23
27
  timeoutMS?: number;
28
+ /** The document image the update writes, when the caller holds one: a fenced save's instance. */
29
+ observedDocument?: object;
24
30
  },
25
31
  ): Promise<plugins.mongodb.UpdateResult> => {
26
32
  return runWithOrdinarySmartdataSession(
@@ -35,7 +41,7 @@ export const executeAtomicUpdate = async <T>(
35
41
  async (rawSessionArg, deadlineArg) => {
36
42
  await deadlineArg.join(collectionArg.init());
37
43
  try {
38
- return await collectionArg.mongoDbCollection.updateOne(
44
+ const result = await collectionArg.mongoDbCollection.updateOne(
39
45
  filterArg,
40
46
  updateArg,
41
47
  {
@@ -44,6 +50,16 @@ export const executeAtomicUpdate = async <T>(
44
50
  ...deadlineArg.commandOptions(),
45
51
  },
46
52
  );
53
+ if (result.upsertedCount > 0 || result.matchedCount > 0) {
54
+ reportSmartdataCollectionWrite(
55
+ collectionArg,
56
+ optsArg?.session,
57
+ result.upsertedCount > 0 ? 'insert' : 'update',
58
+ optsArg?.observedDocument ? [optsArg.observedDocument] : null,
59
+ filterArg,
60
+ );
61
+ }
62
+ return result;
47
63
  } catch (errorArg) {
48
64
  return normalizeOrdinaryPersistenceError(
49
65
  errorArg,
@@ -90,6 +106,25 @@ export const executeAtomicUpsertMany = async <T>(
90
106
  },
91
107
  async (rawSessionArg, deadlineArg) => {
92
108
  await deadlineArg.join(collectionArg.init());
109
+ /**
110
+ * Every entry upserts, so an entry that neither inserted nor failed
111
+ * matched its document.
112
+ */
113
+ const reportEntries = (
114
+ upsertedIdsArg: Record<number, unknown> | undefined,
115
+ failedIndexesArg: ReadonlySet<number>,
116
+ ): void => {
117
+ operationsArg.forEach((operationArg, indexArg) => {
118
+ if (failedIndexesArg.has(indexArg)) return;
119
+ reportSmartdataCollectionWrite(
120
+ collectionArg,
121
+ optsArg?.session,
122
+ Object.hasOwn(upsertedIdsArg ?? {}, indexArg) ? 'insert' : 'update',
123
+ null,
124
+ operationArg.filter,
125
+ );
126
+ });
127
+ };
93
128
  try {
94
129
  const result = await collectionArg.mongoDbCollection.bulkWrite(
95
130
  operationsArg.map((operationArg) => ({
@@ -105,6 +140,9 @@ export const executeAtomicUpsertMany = async <T>(
105
140
  ...deadlineArg.commandOptions(),
106
141
  },
107
142
  );
143
+ if (result.isOk()) {
144
+ reportEntries(result.upsertedIds, new Set());
145
+ }
108
146
  return {
109
147
  acknowledged: result.isOk(),
110
148
  matchedCount: result.matchedCount,
@@ -112,6 +150,14 @@ export const executeAtomicUpsertMany = async <T>(
112
150
  upsertedCount: result.upsertedCount,
113
151
  };
114
152
  } catch (errorArg) {
153
+ // An unordered batch that failed on write errors attempted every entry.
154
+ const failedIndexes = getMongoBulkWriteErrorIndexes(errorArg);
155
+ if (failedIndexes) {
156
+ reportEntries(
157
+ (errorArg as plugins.mongodb.MongoBulkWriteError).result?.upsertedIds,
158
+ failedIndexes,
159
+ );
160
+ }
115
161
  return normalizeOrdinaryPersistenceError(
116
162
  errorArg,
117
163
  `Atomic upsert-many conflicts with a unique index in collection "${collectionArg.collectionName}".`,
@@ -151,7 +197,7 @@ export const executeAtomicUpdateMany = async <T>(
151
197
  async (rawSessionArg, deadlineArg) => {
152
198
  await deadlineArg.join(collectionArg.init());
153
199
  try {
154
- return await collectionArg.mongoDbCollection.updateMany(
200
+ const result = await collectionArg.mongoDbCollection.updateMany(
155
201
  filterArg,
156
202
  updateArg,
157
203
  {
@@ -159,6 +205,10 @@ export const executeAtomicUpdateMany = async <T>(
159
205
  ...deadlineArg.commandOptions(),
160
206
  },
161
207
  );
208
+ if (result.matchedCount > 0) {
209
+ reportSmartdataCollectionWrite(collectionArg, optsArg?.session, 'update', null, filterArg);
210
+ }
211
+ return result;
162
212
  } catch (errorArg) {
163
213
  return normalizeOrdinaryPersistenceError(
164
214
  errorArg,
@@ -6,6 +6,7 @@ import { snapshotBoundedBsonDocument } from './classes.bsonsnapshot.js';
6
6
  import { validateOrdinaryStoredDocument } from './classes.ordinarypersistence.js';
7
7
  import { SmartdataPersistenceError, isMongoDuplicateKeyError } from './classes.persistence.js';
8
8
  import { readBoundedInspectionCursor } from './classes.namespaceinspection.js';
9
+ import { reportSmartdataWrite } from './classes.session.js';
9
10
  import {
10
11
  collectionModelSchemaResolverSymbol, collectionTopologyDbInspectionSymbol,
11
12
  getExpectedCollectionTopologyForSchema, inspectCollectionTopologySnapshot,
@@ -96,7 +97,12 @@ export const insertBootstrapRecord = async (
96
97
  dispatched ? 'Bootstrap insertion outcome is uncertain; inspect its explicit ID before proceeding.'
97
98
  : 'Bootstrap insertion could not inspect the current database connection.');
98
99
  }
99
- if (outcome === 'inserted') return Object.freeze({ acknowledged: true, insertedId: document._id as string });
100
+ if (outcome === 'inserted') {
101
+ reportSmartdataWrite(dbArg, undefined, {
102
+ collection: schema.collectionName, kind: 'insert', documents: [document],
103
+ });
104
+ return Object.freeze({ acknowledged: true, insertedId: document._id as string });
105
+ }
100
106
  const messages = {
101
107
  unique_conflict: 'Bootstrap record conflicts with a unique index.',
102
108
  invalid_configuration: 'Bootstrap insertion requires absent or exact primary-only collection topology.',
@@ -24,13 +24,16 @@ import { CollectionFactory } from './classes.collectionfactory.js';
24
24
  import { logger } from './logging.js';
25
25
  import {
26
26
  SmartdataPersistenceError,
27
+ getMongoBulkWriteErrorIndexes,
27
28
  isMongoIndexConflictError,
28
29
  normalizeOrdinaryPersistenceError,
29
30
  } from './classes.persistence.js';
30
31
  import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
31
32
  import type { SmartdataOperationDeadline } from './classes.operationdeadline.js';
33
+ import { hasSmartdataWriteObservers } from './classes.writeobserver.js';
32
34
  import {
33
35
  leaseOrdinarySmartdataSession,
36
+ reportSmartdataCollectionWrite,
34
37
  runWithOrdinarySmartdataSession,
35
38
  type TSmartdataOrdinarySession,
36
39
  } from './classes.session.js';
@@ -2595,10 +2598,14 @@ export class SmartdataCollection<T> {
2595
2598
 
2596
2599
  const saveableObject = preparedObject ?? await dbDocArg.createSavableObject();
2597
2600
  try {
2598
- return await this.mongoDbCollection.insertOne(saveableObject, {
2601
+ const result = await this.mongoDbCollection.insertOne(saveableObject, {
2599
2602
  session: rawSessionArg,
2600
2603
  ...deadlineArg.commandOptions(),
2601
2604
  });
2605
+ if (result.acknowledged) {
2606
+ reportSmartdataCollectionWrite(this, opts?.session, 'insert', [saveableObject]);
2607
+ }
2608
+ return result;
2602
2609
  } catch (err: any) {
2603
2610
  return normalizeOrdinaryPersistenceError(
2604
2611
  err,
@@ -2707,13 +2714,29 @@ export class SmartdataCollection<T> {
2707
2714
  saveableObjects.push(await dbDocArg.createSavableObject());
2708
2715
  }
2709
2716
  }
2717
+ const reportInserted = (insertedIdsArg: Record<number, unknown> | undefined): void => {
2718
+ const inserted = Object.keys(insertedIdsArg ?? {})
2719
+ .map((indexArg) => saveableObjects[Number(indexArg)])
2720
+ .filter((documentArg) => documentArg !== undefined);
2721
+ if (inserted.length > 0) {
2722
+ reportSmartdataCollectionWrite(this, opts?.session, 'insert', inserted);
2723
+ }
2724
+ };
2710
2725
  try {
2711
- return await this.mongoDbCollection.insertMany(saveableObjects, {
2726
+ const result = await this.mongoDbCollection.insertMany(saveableObjects, {
2712
2727
  session: rawSessionArg,
2713
2728
  ordered: opts?.ordered !== false,
2714
2729
  ...deadlineArg.commandOptions(),
2715
2730
  });
2731
+ if (result.acknowledged) {
2732
+ reportInserted(result.insertedIds);
2733
+ }
2734
+ return result;
2716
2735
  } catch (err: any) {
2736
+ // A batch that failed on write errors names the documents it still inserted.
2737
+ if (getMongoBulkWriteErrorIndexes(err)) {
2738
+ reportInserted((err as plugins.mongodb.MongoBulkWriteError).result?.insertedIds);
2739
+ }
2717
2740
  return normalizeOrdinaryPersistenceError(
2718
2741
  err,
2719
2742
  `Insert conflicts with a unique index in collection "${this.collectionName}".`,
@@ -2756,6 +2779,14 @@ export class SmartdataCollection<T> {
2756
2779
  // seeded body must not repeat the immutable primary key: MongoDB derives
2757
2780
  // it from the filter's equality condition on the insert branch.
2758
2781
  const documentIdIdentity = this.modelSchema?.identityAsDocumentId === identityFieldArg;
2782
+ const reportUpserted = (upsertedIdsArg: Record<number, unknown> | undefined): void => {
2783
+ const inserted = Object.keys(upsertedIdsArg ?? {})
2784
+ .map((indexArg) => prepared[Number(indexArg)])
2785
+ .filter((documentArg) => documentArg !== undefined);
2786
+ if (inserted.length > 0) {
2787
+ reportSmartdataCollectionWrite(this, optsArg.session, 'insert', inserted);
2788
+ }
2789
+ };
2759
2790
  try {
2760
2791
  const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => {
2761
2792
  const { _id: storedId, ...body } = document as Record<string, unknown>;
@@ -2769,12 +2800,16 @@ export class SmartdataCollection<T> {
2769
2800
  },
2770
2801
  };
2771
2802
  }), { ordered: false, session: rawSessionArg, ...deadlineArg.commandOptions() });
2803
+ reportUpserted(result.upsertedIds);
2772
2804
  if (result.upsertedCount + result.matchedCount !== prepared.length) {
2773
2805
  throw new SmartdataPersistenceError('unsupported_operation',
2774
2806
  'insertManyIfAbsent requires an acknowledged result for every identity.');
2775
2807
  }
2776
2808
  return { insertedCount: result.upsertedCount, matchedCount: result.matchedCount };
2777
2809
  } catch (errorArg) {
2810
+ if (getMongoBulkWriteErrorIndexes(errorArg)) {
2811
+ reportUpserted((errorArg as plugins.mongodb.MongoBulkWriteError).result?.upsertedIds);
2812
+ }
2778
2813
  return normalizeOrdinaryPersistenceError(errorArg,
2779
2814
  `Insert conflicts with a unique index in collection "${this.collectionName}".`);
2780
2815
  }
@@ -2825,7 +2860,7 @@ export class SmartdataCollection<T> {
2825
2860
  updateableObject[key] = saveableObject[key];
2826
2861
  }
2827
2862
  try {
2828
- return await this.mongoDbCollection.updateOne(
2863
+ const result = await this.mongoDbCollection.updateOne(
2829
2864
  identifiableObject,
2830
2865
  {
2831
2866
  $set: updateableObject,
@@ -2835,6 +2870,16 @@ export class SmartdataCollection<T> {
2835
2870
  },
2836
2871
  { upsert: true, session: rawSessionArg, ...deadlineArg.commandOptions() },
2837
2872
  );
2873
+ if (result.upsertedCount > 0 || result.matchedCount > 0) {
2874
+ reportSmartdataCollectionWrite(
2875
+ this,
2876
+ opts?.session,
2877
+ result.upsertedCount > 0 ? 'insert' : 'update',
2878
+ [saveableObject],
2879
+ identifiableObject,
2880
+ );
2881
+ }
2882
+ return result;
2838
2883
  } catch (errorArg) {
2839
2884
  return normalizeOrdinaryPersistenceError(
2840
2885
  errorArg,
@@ -2857,10 +2902,24 @@ export class SmartdataCollection<T> {
2857
2902
  await this.checkDoc(dbDocArg);
2858
2903
  const identifiableObject = await dbDocArg.createIdentifiableObject();
2859
2904
  this.assertConstrainingIdentifiableObject(identifiableObject, 'delete');
2860
- return this.mongoDbCollection.deleteOne(identifiableObject, {
2905
+ // Serialized before the delete, so a failing serializer never fails an acknowledged delete.
2906
+ const observedDocument = hasSmartdataWriteObservers(this.smartdataDb)
2907
+ ? await dbDocArg.createSavableObject() as object
2908
+ : undefined;
2909
+ const result = await this.mongoDbCollection.deleteOne(identifiableObject, {
2861
2910
  session: rawSessionArg,
2862
2911
  ...deadlineArg.commandOptions(),
2863
2912
  });
2913
+ if (result.deletedCount > 0) {
2914
+ reportSmartdataCollectionWrite(
2915
+ this,
2916
+ opts?.session,
2917
+ 'delete',
2918
+ observedDocument ? [observedDocument] : null,
2919
+ identifiableObject,
2920
+ );
2921
+ }
2922
+ return result;
2864
2923
  },
2865
2924
  );
2866
2925
  }
@@ -20,6 +20,10 @@ import {
20
20
  type ISmartdataNamespaceInspectionOptions,
21
21
  type ISmartdataNamespaceInspection,
22
22
  } from './classes.namespaceinspection.js';
23
+ import {
24
+ addSmartdataWriteObserver,
25
+ type ISmartdataWriteObserverOptions,
26
+ } from './classes.writeobserver.js';
23
27
 
24
28
  export { SmartdataSession } from './classes.session.js';
25
29
  export type {
@@ -27,6 +31,13 @@ export type {
27
31
  TSmartdataOrdinarySession,
28
32
  TSmartdataTransactionCommitOutcome,
29
33
  } from './classes.session.js';
34
+ export type {
35
+ ISmartdataWriteObserverOptions,
36
+ ISmartdataWriteReport,
37
+ TSmartdataObservedFields,
38
+ TSmartdataObservedValue,
39
+ TSmartdataWriteKind,
40
+ } from './classes.writeobserver.js';
30
41
 
31
42
  import { logger } from './logging.js';
32
43
  import { assertDataOptions, SmartdataOperationBudget } from './classes.operationbudget.js';
@@ -524,6 +535,26 @@ export class SmartdataDb {
524
535
  return createSmartdataSession(this);
525
536
  }
526
537
 
538
+ /**
539
+ * Observes every acknowledged write that this process makes through the
540
+ * database's models: inserts, saves, deletes, atomic and exact writes. Each
541
+ * write is reported once, after the database acknowledged it; a write inside
542
+ * a `createSession()` transaction is reported after the commit, and not at
543
+ * all when the transaction rolls back or its outcome is unknown. A write
544
+ * that changed nothing (no match, nothing deleted) is not reported. Writes
545
+ * of other processes, raw driver access and migrations are not observed.
546
+ *
547
+ * Reports carry the values of the requested top-level `fields` only, from
548
+ * the written documents and from the selector's equality conditions. While
549
+ * an observer is registered, transactions on raw `startSession()` sessions
550
+ * are refused for model writes, since their outcome is not known here.
551
+ *
552
+ * Returns the function that stops the observer.
553
+ */
554
+ public observeWrites(optionsArg: ISmartdataWriteObserverOptions): () => void {
555
+ return addSmartdataWriteObserver(this, optionsArg);
556
+ }
557
+
527
558
  /**
528
559
  * Creates a narrowly scoped MongoDB administration facade bound to this
529
560
  * SmartData lifecycle. Applications never need direct driver access.
@@ -6851,7 +6851,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
6851
6851
  $set: setObject,
6852
6852
  ...(Object.keys(unsetObject).length > 0 ? { $unset: unsetObject } : {}),
6853
6853
  },
6854
- { session: options.session, timeoutMS: options.timeoutMS },
6854
+ { session: options.session, timeoutMS: options.timeoutMS, observedDocument: saveableObject },
6855
6855
  );
6856
6856
  if (result.matchedCount === 0) {
6857
6857
  // Nothing was written, so the instance keeps the update timestamp the
@@ -9,6 +9,7 @@ import { registerCollectionReconnectHandler } from './classes.collectionlifecycl
9
9
  import type { SmartdataSession } from './classes.session.js';
10
10
  import {
11
11
  acquireSmartdataSession,
12
+ reportSmartdataCollectionWrite,
12
13
  settleSmartdataOperationError,
13
14
  resolveSmartdataOperationDeadline,
14
15
  } from './classes.session.js';
@@ -1549,6 +1550,14 @@ export class SmartdataExactCollection<
1549
1550
  }
1550
1551
  }
1551
1552
 
1553
+ /** A reconciled body is this call's own insert only when it carries this call's `_id`. */
1554
+ const reportReconciledInsert = (storedArg: TStoredDocument<TPersisted>): void => {
1555
+ if (storedArg._id.equals(expectedStoredDocument._id)) {
1556
+ reportSmartdataCollectionWrite(
1557
+ this.collection, optionsArg.session, 'insert', [expectedStoredDocument],
1558
+ );
1559
+ }
1560
+ };
1552
1561
  let result: plugins.mongodb.InsertOneResult;
1553
1562
  const insertOptions = { session: sessionArg, ...deadlineArg.commandOptions() };
1554
1563
  try {
@@ -1594,6 +1603,7 @@ export class SmartdataExactCollection<
1594
1603
  }
1595
1604
  if (reconciled) {
1596
1605
  if (bytesEqual(reconciled.bodyBytes, persistedBytes)) {
1606
+ reportReconciledInsert(reconciled.document);
1597
1607
  return { status: 'already_committed', document: reconciled.document };
1598
1608
  }
1599
1609
  if (isDuplicateKeyError(errorArg)) {
@@ -1633,6 +1643,7 @@ export class SmartdataExactCollection<
1633
1643
  );
1634
1644
  }
1635
1645
  if (reconciled && bytesEqual(reconciled.bodyBytes, persistedBytes)) {
1646
+ reportReconciledInsert(reconciled.document);
1636
1647
  return { status: 'already_committed', document: reconciled.document };
1637
1648
  }
1638
1649
  throw this.ambiguousWriteError(
@@ -1640,6 +1651,9 @@ export class SmartdataExactCollection<
1640
1651
  result,
1641
1652
  );
1642
1653
  }
1654
+ reportSmartdataCollectionWrite(
1655
+ this.collection, optionsArg.session, 'insert', [expectedStoredDocument],
1656
+ );
1643
1657
  return { status: 'inserted', document: expectedStoredDocument };
1644
1658
  });
1645
1659
  }
@@ -1827,6 +1841,11 @@ export class SmartdataExactCollection<
1827
1841
  ? { _id: current._id, _smartdataRevision: current._smartdataRevision }
1828
1842
  : { _id: current._id, _smartdataRevision: { $exists: false } };
1829
1843
 
1844
+ const reportTransition = (): void => {
1845
+ reportSmartdataCollectionWrite(
1846
+ this.collection, sessionOptionsArg.session, 'replace', [current, expectedStoredDocument], selector,
1847
+ );
1848
+ };
1830
1849
  const replaceOptions = {
1831
1850
  returnDocument: 'after' as const,
1832
1851
  includeResultMetadata: false as const,
@@ -1851,6 +1870,7 @@ export class SmartdataExactCollection<
1851
1870
  'Exact transition returned a postimage different from the requested state.',
1852
1871
  );
1853
1872
  }
1873
+ reportTransition();
1854
1874
  return { status: 'transitioned', document: expectedStoredDocument };
1855
1875
  } catch (errorArg) {
1856
1876
  if (isTransientTransactionError(errorArg, sessionArg)) {
@@ -1875,6 +1895,7 @@ export class SmartdataExactCollection<
1875
1895
  }, { session: sessionArg, ...deadlineArg.commandOptions() });
1876
1896
  if (reconciledRaw) {
1877
1897
  if (this.encodesAs(reconciledRaw, expectedBytes)) {
1898
+ reportTransition();
1878
1899
  return { status: 'transitioned', document: expectedStoredDocument };
1879
1900
  }
1880
1901
  // A malformed document fails the reconciliation itself.
@@ -1936,6 +1957,9 @@ export class SmartdataExactCollection<
1936
1957
  result.acknowledged === true &&
1937
1958
  result.deletedCount === 1
1938
1959
  ) {
1960
+ reportSmartdataCollectionWrite(
1961
+ this.collection, sessionOptionsArg.session, 'delete', [current], selector,
1962
+ );
1939
1963
  return { status: 'deleted', deletedCount: 1 };
1940
1964
  }
1941
1965
  if (
@@ -78,6 +78,22 @@ export const isMongoTransientTransactionError = (errorArg: unknown): boolean =>
78
78
  errorArg instanceof plugins.mongodb.MongoError
79
79
  && errorArg.hasErrorLabel(plugins.mongodb.MongoErrorLabel.TransientTransactionError);
80
80
 
81
+ /**
82
+ * The failed operations of a bulk write that failed on write errors. Only then
83
+ * does the error's result state which operations applied: after a driver or
84
+ * write-concern error the outcome is unknown and this returns `undefined`.
85
+ */
86
+ export const getMongoBulkWriteErrorIndexes = (
87
+ errorArg: unknown,
88
+ ): ReadonlySet<number> | undefined => {
89
+ if (!(errorArg instanceof plugins.mongodb.MongoBulkWriteError)) return undefined;
90
+ if (errorArg.result?.getWriteConcernError() !== undefined) return undefined;
91
+ const writeErrors = [errorArg.writeErrors].flat();
92
+ return writeErrors.length > 0
93
+ ? new Set(writeErrors.map((writeErrorArg) => writeErrorArg.index))
94
+ : undefined;
95
+ };
96
+
81
97
  export const isMongoDuplicateKeyError = (
82
98
  errorArg: unknown,
83
99
  ): errorArg is plugins.mongodb.MongoServerError => {
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
 
3
+ import type { SmartdataCollection } from './classes.collection.js';
3
4
  import type { SmartdataDb } from './classes.db.js';
4
5
  import {
5
6
  SmartdataOperationDeadline,
@@ -15,6 +16,12 @@ import {
15
16
  normalizeDeadlineError,
16
17
  normalizeOrdinaryPersistenceError,
17
18
  } from './classes.persistence.js';
19
+ import {
20
+ captureSmartdataWrite,
21
+ hasSmartdataWriteObservers,
22
+ type ISmartdataWrite,
23
+ type TSmartdataWriteKind,
24
+ } from './classes.writeobserver.js';
18
25
 
19
26
  interface ISmartdataSessionBinding {
20
27
  session: SmartdataSession;
@@ -36,6 +43,8 @@ interface ISmartdataSessionBinding {
36
43
  transactionCommitReached: boolean;
37
44
  /** Set when the session ended while a transaction ran, stating how far the transaction had got. */
38
45
  transactionEnded?: TSmartdataTransactionEnd;
46
+ /** The write reports of the current attempt, delivered once the transaction committed. */
47
+ transactionWrites: Array<() => void>;
39
48
  operationSettled?: Promise<void>;
40
49
  resolveOperationSettled?: () => void;
41
50
  invalidated: boolean;
@@ -307,6 +316,7 @@ export class SmartdataSession {
307
316
  binding.transactionUsesOrdinaryWrite = false;
308
317
  binding.transactionFailure = undefined;
309
318
  binding.transactionCommitReached = false;
319
+ binding.transactionWrites = [];
310
320
  callbackError = undefined;
311
321
  attemptInterruption = undefined;
312
322
  try {
@@ -435,11 +445,13 @@ export class SmartdataSession {
435
445
  };
436
446
  let result: TResult | undefined;
437
447
  let transactionEnded: TSmartdataTransactionEnd | undefined;
448
+ let committedWrites: Array<() => void> = [];
438
449
  try {
439
450
  result = timeoutMS === undefined
440
451
  ? await binding.rawSession.withTransaction(runAttempt)
441
452
  : await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
442
453
  transactionEnded = binding.transactionEnded;
454
+ committedWrites = binding.transactionWrites;
443
455
  } catch (errorArg) {
444
456
  const { error, outcome } = settleTransactionRejection(errorArg);
445
457
  throw rejectTransaction(error, outcome);
@@ -451,6 +463,7 @@ export class SmartdataSession {
451
463
  binding.transactionFailure = undefined;
452
464
  binding.transactionCommitReached = false;
453
465
  binding.transactionEnded = undefined;
466
+ binding.transactionWrites = [];
454
467
  }
455
468
  if (transactionEnded) {
456
469
  throw rejectTransaction(
@@ -458,6 +471,9 @@ export class SmartdataSession {
458
471
  commitOutcomeOfTransactionEnd(transactionEnded),
459
472
  );
460
473
  }
474
+ for (const deliverWrite of committedWrites) {
475
+ deliverWrite();
476
+ }
461
477
  return result;
462
478
  }
463
479
 
@@ -495,6 +511,7 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
495
511
  transactionCallbackOpen: false,
496
512
  transactionUsesOrdinaryWrite: false,
497
513
  transactionCommitReached: false,
514
+ transactionWrites: [],
498
515
  invalidated: false,
499
516
  };
500
517
  bindings.set(session, binding);
@@ -656,6 +673,17 @@ export const leaseOrdinarySmartdataSession = (
656
673
  if (!optionsArg.prepared && sessionArg?.inTransaction()) {
657
674
  refuseUnpreparedCollection(optionsArg, 'inside a transaction');
658
675
  }
676
+ if (
677
+ optionsArg.ordinaryWrite
678
+ && sessionArg?.inTransaction()
679
+ && hasSmartdataWriteObservers(ownerArg)
680
+ ) {
681
+ // Only an owned transaction tells SmartData whether it committed.
682
+ throw new SmartdataPersistenceError(
683
+ 'unsupported_operation',
684
+ 'Writes of an observed database run in transactions of db.createSession(), not raw driver sessions.',
685
+ );
686
+ }
659
687
  return { rawSession: sessionArg, release: () => {} };
660
688
  }
661
689
  if (!optionsArg.prepared) {
@@ -753,6 +781,51 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
753
781
  }
754
782
  };
755
783
 
784
+ /**
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.
789
+ */
790
+ export const reportSmartdataWrite = (
791
+ ownerArg: SmartdataDb,
792
+ sessionArg: TSmartdataOrdinarySession | undefined,
793
+ writeArg: ISmartdataWrite,
794
+ ): 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;
798
+ const deliver = captureSmartdataWrite(ownerArg, writeArg);
799
+ if (!deliver) return;
800
+ 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
+ }
807
+ return;
808
+ }
809
+ deliver();
810
+ };
811
+
812
+ /** Reports one acknowledged write of a model collection; see `reportSmartdataWrite`. */
813
+ export const reportSmartdataCollectionWrite = (
814
+ collectionArg: SmartdataCollection<any>,
815
+ sessionArg: TSmartdataOrdinarySession | undefined,
816
+ kindArg: TSmartdataWriteKind,
817
+ documentsArg: readonly object[] | null,
818
+ selectorArg?: unknown,
819
+ ): void => {
820
+ reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
821
+ collection: collectionArg.collectionName,
822
+ kind: kindArg,
823
+ documents: documentsArg,
824
+ selector: selectorArg,
825
+ identityDocumentIdField: collectionArg.getBoundModelSchema()?.identityAsDocumentId,
826
+ });
827
+ };
828
+
756
829
  /**
757
830
  * Ends every session of the owner. `endOptionsArg.timeoutMS` is the driver's
758
831
  * deadline for aborting a transaction a session still holds.