@lossless.org/client 1.19.0 → 1.20.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 (34) 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 +28 -21
  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/dist_ts/nosqldb/connectionstring.d.ts +15 -0
  19. package/dist_ts/nosqldb/connectionstring.js +194 -0
  20. package/package.json +3 -3
  21. package/readme.md +3 -2
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  24. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +22 -0
  25. package/ts/nosqldb/classes.atomicupdate.ts +53 -3
  26. package/ts/nosqldb/classes.bootstraprecord.ts +7 -1
  27. package/ts/nosqldb/classes.collection.ts +63 -4
  28. package/ts/nosqldb/classes.db.ts +42 -22
  29. package/ts/nosqldb/classes.doc.ts +1 -1
  30. package/ts/nosqldb/classes.exactpersistence.ts +24 -0
  31. package/ts/nosqldb/classes.persistence.ts +16 -0
  32. package/ts/nosqldb/classes.session.ts +73 -0
  33. package/ts/nosqldb/classes.writeobserver.ts +224 -0
  34. package/ts/nosqldb/connectionstring.ts +243 -0
@@ -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,8 +31,16 @@ 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';
43
+ import { buildSmartdataConnectionUrl, redactSmartdataConnectionError } from './connectionstring.js';
32
44
  import { assertDataOptions, SmartdataOperationBudget } from './classes.operationbudget.js';
33
45
  import {
34
46
  composeSmartdataCapabilities,
@@ -247,28 +259,11 @@ export class SmartdataDb {
247
259
  try { budget?.check(); } catch (errorArg) { budget?.close(); throw errorArg; }
248
260
  this.beginLifecycleOperation('init');
249
261
  this.transitionLifecycle('connecting');
262
+ // Read once, so a failure is redacted with the credentials this attempt connected with.
263
+ const descriptor = this.smartdataOptions;
250
264
  let candidateClient: plugins.mongodb.MongoClient | undefined;
251
265
  try {
252
266
  try {
253
- // Safely encode credentials to handle special characters
254
- const encodedUser = this.smartdataOptions.mongoDbUser
255
- ? encodeURIComponent(this.smartdataOptions.mongoDbUser)
256
- : '';
257
- const encodedPass = this.smartdataOptions.mongoDbPass
258
- ? encodeURIComponent(this.smartdataOptions.mongoDbPass)
259
- : '';
260
-
261
- const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
262
- .replace('<USERNAME>', encodedUser)
263
- .replace('<username>', encodedUser)
264
- .replace('<USER>', encodedUser)
265
- .replace('<user>', encodedUser)
266
- .replace('<PASSWORD>', encodedPass)
267
- .replace('<password>', encodedPass)
268
- .replace('<DBNAME>', this.smartdataOptions.mongoDbName || '')
269
- .replace('<dbname>', this.smartdataOptions.mongoDbName || '');
270
-
271
- const descriptor = this.smartdataOptions;
272
267
  const clientOptions: plugins.mongodb.MongoClientOptions = {
273
268
  maxPoolSize: descriptor.maxPoolSize ?? 100,
274
269
  maxIdleTimeMS: descriptor.maxIdleTimeMS ?? 300000,
@@ -279,7 +274,8 @@ export class SmartdataDb {
279
274
  retryWrites: true,
280
275
  };
281
276
 
282
- candidateClient = new plugins.mongodb.MongoClient(finalConnectionUrl, clientOptions);
277
+ candidateClient = new plugins.mongodb.MongoClient(
278
+ buildSmartdataConnectionUrl(descriptor), clientOptions);
283
279
  this.pendingClient = candidateClient;
284
280
  const connectingClient = candidateClient;
285
281
  let cancellationCleanup: Promise<void> | undefined;
@@ -336,9 +332,13 @@ export class SmartdataDb {
336
332
  ? 'SmartData connection setup and cleanup failed; close must be retried.'
337
333
  : 'SmartData connection setup failed or was cancelled.');
338
334
  }
339
- if (cleanupError) throw new AggregateError([error, cleanupError],
335
+ const setupError = new SmartdataPersistenceError('unsupported_operation',
336
+ 'SmartData connection setup failed.',
337
+ { cause: redactSmartdataConnectionError(error, descriptor) });
338
+ if (cleanupError) throw new AggregateError(
339
+ [setupError, redactSmartdataConnectionError(cleanupError, descriptor)],
340
340
  'SmartData connection setup and cleanup failed; close must be retried.');
341
- throw error;
341
+ throw setupError;
342
342
  }
343
343
  } finally {
344
344
  this.endLifecycleOperation('init');
@@ -524,6 +524,26 @@ export class SmartdataDb {
524
524
  return createSmartdataSession(this);
525
525
  }
526
526
 
527
+ /**
528
+ * Observes every acknowledged write that this process makes through the
529
+ * database's models: inserts, saves, deletes, atomic and exact writes. Each
530
+ * write is reported once, after the database acknowledged it; a write inside
531
+ * a `createSession()` transaction is reported after the commit, and not at
532
+ * all when the transaction rolls back or its outcome is unknown. A write
533
+ * that changed nothing (no match, nothing deleted) is not reported. Writes
534
+ * of other processes, raw driver access and migrations are not observed.
535
+ *
536
+ * Reports carry the values of the requested top-level `fields` only, from
537
+ * the written documents and from the selector's equality conditions. While
538
+ * an observer is registered, transactions on raw `startSession()` sessions
539
+ * are refused for model writes, since their outcome is not known here.
540
+ *
541
+ * Returns the function that stops the observer.
542
+ */
543
+ public observeWrites(optionsArg: ISmartdataWriteObserverOptions): () => void {
544
+ return addSmartdataWriteObserver(this, optionsArg);
545
+ }
546
+
527
547
  /**
528
548
  * Creates a narrowly scoped MongoDB administration facade bound to this
529
549
  * 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.
@@ -0,0 +1,224 @@
1
+ import type { SmartdataDb } from './classes.db.js';
2
+ import { assertDataOptions } from './classes.operationbudget.js';
3
+ import { SmartdataPersistenceError } from './classes.persistence.js';
4
+ import { logger } from './logging.js';
5
+
6
+ /** What a write did to its documents. An upsert that inserted is an `insert`. */
7
+ export type TSmartdataWriteKind = 'insert' | 'update' | 'replace' | 'delete';
8
+
9
+ /** The values a report carries; any other stored value is left out. */
10
+ export type TSmartdataObservedValue = string | number | boolean | null;
11
+
12
+ /** The observed fields of one document or selector, by field name. */
13
+ export type TSmartdataObservedFields = Readonly<Record<string, TSmartdataObservedValue>>;
14
+
15
+ /** One acknowledged write, reported to every write observer of its database. */
16
+ export interface ISmartdataWriteReport {
17
+ /** The collection the write went to. */
18
+ readonly collection: string;
19
+ readonly kind: TSmartdataWriteKind;
20
+ /**
21
+ * The observed fields of each document the write named or returned: the
22
+ * inserted documents, a model instance's save or delete, an exact write, the
23
+ * document a find-one-and-modify returned. `null` when the write addressed
24
+ * its documents by selector only; `anchors` then says what it pinned.
25
+ */
26
+ readonly documents: readonly TSmartdataObservedFields[] | null;
27
+ /**
28
+ * The observed fields the write's selector pinned by top-level equality
29
+ * (`{ field: value }`, `{ field: { $eq: value } }`, or inside `$and`).
30
+ * Empty for an insert, which has no selector.
31
+ */
32
+ readonly anchors: TSmartdataObservedFields;
33
+ }
34
+
35
+ export interface ISmartdataWriteObserverOptions {
36
+ /** Top-level field names the reports carry: 1 to 64 distinct names, no `.` and no leading `$`. */
37
+ fields: readonly string[];
38
+ /**
39
+ * Called synchronously once per write. A listener that throws is logged and
40
+ * never fails the write; one that needs to do async work schedules it.
41
+ */
42
+ listener: (reportArg: ISmartdataWriteReport) => void;
43
+ }
44
+
45
+ /** @internal What a write path states about one acknowledged write. */
46
+ export interface ISmartdataWrite {
47
+ collection: string;
48
+ kind: TSmartdataWriteKind;
49
+ /** The documents as written or returned; `null` for a write by selector only. */
50
+ documents: readonly object[] | null;
51
+ selector?: unknown;
52
+ /** The model's `identityAsDocumentId` field, whose value a selector carries as `_id`. */
53
+ identityDocumentIdField?: string;
54
+ }
55
+
56
+ interface IWriteObserver {
57
+ fields: readonly string[];
58
+ listener: (reportArg: ISmartdataWriteReport) => void;
59
+ }
60
+
61
+ const maximumObservedFields = 64;
62
+ const observersByDb = new WeakMap<SmartdataDb, Set<IWriteObserver>>();
63
+
64
+ const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
65
+ const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
66
+ if (typeof listener !== 'function') {
67
+ throw new SmartdataPersistenceError('invalid_argument',
68
+ 'A SmartData write observer requires a listener function.');
69
+ }
70
+ if (
71
+ !Array.isArray(fields)
72
+ || fields.length === 0
73
+ || fields.length > maximumObservedFields
74
+ || fields.some((fieldArg) =>
75
+ typeof fieldArg !== 'string'
76
+ || fieldArg.length === 0
77
+ || fieldArg.includes('.')
78
+ || fieldArg.startsWith('$'))
79
+ || new Set(fields).size !== fields.length
80
+ ) {
81
+ throw new SmartdataPersistenceError('invalid_argument',
82
+ `A SmartData write observer requires 1 to ${maximumObservedFields} distinct top-level field names.`);
83
+ }
84
+ return {
85
+ fields: Object.freeze([...fields] as string[]),
86
+ listener: listener as IWriteObserver['listener'],
87
+ };
88
+ };
89
+
90
+ /** @internal Registers a write observer; the returned function removes it. */
91
+ export const addSmartdataWriteObserver = (
92
+ dbArg: SmartdataDb,
93
+ optionsArg: ISmartdataWriteObserverOptions,
94
+ ): (() => void) => {
95
+ const observer = normalizeObserverOptions(optionsArg);
96
+ let observers = observersByDb.get(dbArg);
97
+ if (!observers) {
98
+ observers = new Set();
99
+ observersByDb.set(dbArg, observers);
100
+ }
101
+ observers.add(observer);
102
+ return () => {
103
+ observersByDb.get(dbArg)?.delete(observer);
104
+ };
105
+ };
106
+
107
+ /** @internal */
108
+ export const hasSmartdataWriteObservers = (dbArg: SmartdataDb): boolean =>
109
+ (observersByDb.get(dbArg)?.size ?? 0) > 0;
110
+
111
+ const observedValueOf = (valueArg: unknown): TSmartdataObservedValue | undefined =>
112
+ valueArg === null
113
+ || typeof valueArg === 'string'
114
+ || typeof valueArg === 'boolean'
115
+ || (typeof valueArg === 'number' && Number.isFinite(valueArg))
116
+ ? valueArg as TSmartdataObservedValue
117
+ : undefined;
118
+
119
+ const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
120
+ if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
121
+ return false;
122
+ }
123
+ const prototype = Object.getPrototypeOf(valueArg);
124
+ return prototype === Object.prototype || prototype === null;
125
+ };
126
+
127
+ const pickDocumentFields = (
128
+ documentArg: object,
129
+ fieldsArg: readonly string[],
130
+ ): Record<string, TSmartdataObservedValue> => {
131
+ const picked: Record<string, TSmartdataObservedValue> = {};
132
+ for (const field of fieldsArg) {
133
+ if (!Object.hasOwn(documentArg, field)) continue;
134
+ const value = observedValueOf((documentArg as Record<string, unknown>)[field]);
135
+ if (value !== undefined) picked[field] = value;
136
+ }
137
+ return picked;
138
+ };
139
+
140
+ /**
141
+ * A `null` condition also matches a missing field, so only concrete values
142
+ * anchor a selector.
143
+ */
144
+ const collectSelectorAnchors = (
145
+ selectorArg: unknown,
146
+ fieldsArg: ReadonlySet<string>,
147
+ identityDocumentIdFieldArg: string | undefined,
148
+ anchorsArg: Record<string, TSmartdataObservedValue>,
149
+ ): void => {
150
+ if (!isPlainRecord(selectorArg)) return;
151
+ for (const key of Object.keys(selectorArg)) {
152
+ const condition = selectorArg[key];
153
+ if (key === '$and') {
154
+ if (Array.isArray(condition)) {
155
+ for (const conjunct of condition) {
156
+ collectSelectorAnchors(conjunct, fieldsArg, identityDocumentIdFieldArg, anchorsArg);
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+ let value = observedValueOf(condition);
162
+ if (
163
+ value === undefined
164
+ && isPlainRecord(condition)
165
+ && Object.keys(condition).length === 1
166
+ && Object.hasOwn(condition, '$eq')
167
+ ) {
168
+ value = observedValueOf(condition.$eq);
169
+ }
170
+ if (value === undefined || value === null) continue;
171
+ const fields = key === '_id' && identityDocumentIdFieldArg
172
+ ? [key, identityDocumentIdFieldArg]
173
+ : [key];
174
+ for (const field of fields) {
175
+ if (fieldsArg.has(field) && !Object.hasOwn(anchorsArg, field)) {
176
+ anchorsArg[field] = value;
177
+ }
178
+ }
179
+ }
180
+ };
181
+
182
+ const describeError = (errorArg: unknown): string =>
183
+ errorArg instanceof Error ? errorArg.message : String(errorArg);
184
+
185
+ /**
186
+ * @internal Captures one acknowledged write for the observers registered now,
187
+ * and returns the delivery, or `undefined` when nobody observes the database.
188
+ * The observed values are copied at capture, so a delivery deferred to a
189
+ * transaction's commit reports the write as it was made. An observer removed
190
+ * before the delivery receives nothing.
191
+ */
192
+ export const captureSmartdataWrite = (
193
+ dbArg: SmartdataDb,
194
+ writeArg: ISmartdataWrite,
195
+ ): (() => void) | undefined => {
196
+ const registered = observersByDb.get(dbArg);
197
+ if (!registered || registered.size === 0) return undefined;
198
+ const observers = [...registered];
199
+ const fields = [...new Set(observers.flatMap((observerArg) => observerArg.fields))];
200
+ const documents = writeArg.documents?.map((documentArg) =>
201
+ pickDocumentFields(documentArg, fields)) ?? null;
202
+ const anchors: Record<string, TSmartdataObservedValue> = {};
203
+ collectSelectorAnchors(writeArg.selector, new Set(fields), writeArg.identityDocumentIdField, anchors);
204
+ return () => {
205
+ for (const observer of observers) {
206
+ if (!observersByDb.get(dbArg)?.has(observer)) continue;
207
+ const report: ISmartdataWriteReport = Object.freeze({
208
+ collection: writeArg.collection,
209
+ kind: writeArg.kind,
210
+ documents: documents === null
211
+ ? null
212
+ : Object.freeze(documents.map((documentArg) =>
213
+ Object.freeze(pickDocumentFields(documentArg, observer.fields)))),
214
+ anchors: Object.freeze(pickDocumentFields(anchors, observer.fields)),
215
+ });
216
+ try {
217
+ observer.listener(report);
218
+ } catch (errorArg) {
219
+ logger.log('error',
220
+ `A SmartData write observer threw on a write to "${writeArg.collection}": ${describeError(errorArg)}`);
221
+ }
222
+ }
223
+ };
224
+ };