@lossless.org/client 1.22.1 → 1.23.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 (35) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.bootstraprecord.js +4 -4
  3. package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
  4. package/dist_ts/nosqldb/classes.connection.js +3 -2
  5. package/dist_ts/nosqldb/classes.db.d.ts +31 -0
  6. package/dist_ts/nosqldb/classes.db.js +45 -2
  7. package/dist_ts/nosqldb/classes.doc.js +5 -5
  8. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +3 -2
  9. package/dist_ts/nosqldb/classes.exactpersistence.js +8 -7
  10. package/dist_ts/nosqldb/classes.readtracker.d.ts +6 -4
  11. package/dist_ts/nosqldb/classes.readtracker.js +42 -10
  12. package/dist_ts/nosqldb/classes.session.d.ts +7 -6
  13. package/dist_ts/nosqldb/classes.session.js +91 -23
  14. package/dist_ts/nosqldb/classes.writelog.d.ts +129 -0
  15. package/dist_ts/nosqldb/classes.writelog.js +497 -0
  16. package/dist_ts/nosqldb/classes.writeobserver.d.ts +13 -0
  17. package/dist_ts/nosqldb/classes.writeobserver.js +47 -25
  18. package/dist_ts/nosqldb/classes.writerevisions.d.ts +18 -7
  19. package/dist_ts/nosqldb/classes.writerevisions.js +179 -30
  20. package/dist_ts/nosqldb/engineidentity.d.ts +6 -0
  21. package/dist_ts/nosqldb/engineidentity.js +14 -1
  22. package/package.json +2 -2
  23. package/readme.md +11 -10
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/nosqldb/classes.bootstraprecord.ts +3 -3
  26. package/ts/nosqldb/classes.connection.ts +5 -1
  27. package/ts/nosqldb/classes.db.ts +68 -0
  28. package/ts/nosqldb/classes.doc.ts +4 -4
  29. package/ts/nosqldb/classes.exactpersistence.ts +8 -7
  30. package/ts/nosqldb/classes.readtracker.ts +41 -7
  31. package/ts/nosqldb/classes.session.ts +107 -26
  32. package/ts/nosqldb/classes.writelog.ts +701 -0
  33. package/ts/nosqldb/classes.writeobserver.ts +62 -28
  34. package/ts/nosqldb/classes.writerevisions.ts +224 -32
  35. package/ts/nosqldb/engineidentity.ts +16 -0
@@ -77,28 +77,37 @@ interface IWriteObserver {
77
77
  const maximumObservedFields = 64;
78
78
  const observersByDb = new WeakMap<SmartdataDb, Set<IWriteObserver>>();
79
79
 
80
- const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
81
- const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
82
- if (typeof listener !== 'function') {
83
- throw new SmartdataPersistenceError('invalid_argument',
84
- 'A SmartData write observer requires a listener function.');
85
- }
80
+ /**
81
+ * @internal Validates the top-level field names a report carries: 1 to 64
82
+ * distinct names, no `.` and no leading `$`. `subjectArg` names what requires
83
+ * them in the refusal.
84
+ */
85
+ export const normalizeObservedFields = (fieldsArg: unknown, subjectArg: string): readonly string[] => {
86
86
  if (
87
- !Array.isArray(fields)
88
- || fields.length === 0
89
- || fields.length > maximumObservedFields
90
- || fields.some((fieldArg) =>
87
+ !Array.isArray(fieldsArg)
88
+ || fieldsArg.length === 0
89
+ || fieldsArg.length > maximumObservedFields
90
+ || fieldsArg.some((fieldArg) =>
91
91
  typeof fieldArg !== 'string'
92
92
  || fieldArg.length === 0
93
93
  || fieldArg.includes('.')
94
94
  || fieldArg.startsWith('$'))
95
- || new Set(fields).size !== fields.length
95
+ || new Set(fieldsArg).size !== fieldsArg.length
96
96
  ) {
97
97
  throw new SmartdataPersistenceError('invalid_argument',
98
- `A SmartData write observer requires 1 to ${maximumObservedFields} distinct top-level field names.`);
98
+ `${subjectArg} requires 1 to ${maximumObservedFields} distinct top-level field names.`);
99
+ }
100
+ return Object.freeze([...fieldsArg] as string[]);
101
+ };
102
+
103
+ const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
104
+ const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
105
+ if (typeof listener !== 'function') {
106
+ throw new SmartdataPersistenceError('invalid_argument',
107
+ 'A SmartData write observer requires a listener function.');
99
108
  }
100
109
  return {
101
- fields: Object.freeze([...fields] as string[]),
110
+ fields: normalizeObservedFields(fields, 'A SmartData write observer'),
102
111
  listener: listener as IWriteObserver['listener'],
103
112
  };
104
113
  };
@@ -195,6 +204,41 @@ const collectSelectorAnchors = (
195
204
  }
196
205
  };
197
206
 
207
+ /**
208
+ * @internal The report of one acknowledged write with the values of `fieldsArg`,
209
+ * copied now and frozen.
210
+ */
211
+ export const reportOfSmartdataWrite = (
212
+ writeArg: ISmartdataWrite,
213
+ fieldsArg: readonly string[],
214
+ ): ISmartdataWriteReport => {
215
+ const anchors: Record<string, TSmartdataObservedValue> = {};
216
+ collectSelectorAnchors(writeArg.selector, new Set(fieldsArg), writeArg.identityDocumentIdField, anchors);
217
+ return Object.freeze({
218
+ collection: writeArg.collection,
219
+ kind: writeArg.kind,
220
+ documents: writeArg.documents === null
221
+ ? null
222
+ : Object.freeze(writeArg.documents.map((documentArg) =>
223
+ Object.freeze(pickDocumentFields(documentArg, fieldsArg)))),
224
+ anchors: Object.freeze(anchors),
225
+ });
226
+ };
227
+
228
+ /** @internal The same report with only the values of `fieldsArg`, which the report's fields include. */
229
+ export const narrowSmartdataWriteReport = (
230
+ reportArg: ISmartdataWriteReport,
231
+ fieldsArg: readonly string[],
232
+ ): ISmartdataWriteReport => Object.freeze({
233
+ collection: reportArg.collection,
234
+ kind: reportArg.kind,
235
+ documents: reportArg.documents === null
236
+ ? null
237
+ : Object.freeze(reportArg.documents.map((documentArg) =>
238
+ Object.freeze(pickDocumentFields(documentArg, fieldsArg)))),
239
+ anchors: Object.freeze(pickDocumentFields(reportArg.anchors, fieldsArg)),
240
+ });
241
+
198
242
  const describeError = (errorArg: unknown): string =>
199
243
  errorArg instanceof Error ? errorArg.message : String(errorArg);
200
244
 
@@ -212,25 +256,15 @@ export const captureSmartdataWrite = (
212
256
  const registered = observersByDb.get(dbArg);
213
257
  if (!registered || registered.size === 0) return undefined;
214
258
  const observers = [...registered];
215
- const fields = [...new Set(observers.flatMap((observerArg) => observerArg.fields))];
216
- const documents = writeArg.documents?.map((documentArg) =>
217
- pickDocumentFields(documentArg, fields)) ?? null;
218
- const anchors: Record<string, TSmartdataObservedValue> = {};
219
- collectSelectorAnchors(writeArg.selector, new Set(fields), writeArg.identityDocumentIdField, anchors);
259
+ const report = reportOfSmartdataWrite(
260
+ writeArg,
261
+ [...new Set(observers.flatMap((observerArg) => observerArg.fields))],
262
+ );
220
263
  return () => {
221
264
  for (const observer of observers) {
222
265
  if (!observersByDb.get(dbArg)?.has(observer)) continue;
223
- const report: ISmartdataWriteReport = Object.freeze({
224
- collection: writeArg.collection,
225
- kind: writeArg.kind,
226
- documents: documents === null
227
- ? null
228
- : Object.freeze(documents.map((documentArg) =>
229
- Object.freeze(pickDocumentFields(documentArg, observer.fields)))),
230
- anchors: Object.freeze(pickDocumentFields(anchors, observer.fields)),
231
- });
232
266
  try {
233
- observer.listener(report);
267
+ observer.listener(narrowSmartdataWriteReport(report, observer.fields));
234
268
  } catch (errorArg) {
235
269
  logger.log('error',
236
270
  `A SmartData write observer threw on a write to "${writeArg.collection}": ${describeError(errorArg)}`);
@@ -4,12 +4,18 @@ import type { SmartdataDb } from './classes.db.js';
4
4
  import { assertDataOptions } from './classes.operationbudget.js';
5
5
  import type { SmartdataOperationDeadline } from './classes.operationdeadline.js';
6
6
  import { SmartdataPersistenceError, isMongoDuplicateKeyError } from './classes.persistence.js';
7
+ import { logger } from './logging.js';
7
8
 
8
9
  /**
9
10
  * Write revisions let a read set tell writes of other processes apart from
10
11
  * no writes at all. Every model write bumps a counter per collection and
11
12
  * partition in the same transaction, and a read set compares the counters it
12
13
  * read before its reads with the current ones.
14
+ *
15
+ * A bump inserts an entry of its own instead of incrementing a shared
16
+ * document, so concurrent transactions of one partition never conflict on its
17
+ * counter. A counter's value is its base document plus its entries; a
18
+ * background compaction folds entries into the base, which keeps the sum.
13
19
  */
14
20
  export interface ISmartdataWriteRevisionsOptions {
15
21
  /**
@@ -32,6 +38,17 @@ interface IWriteRevisionDocument {
32
38
 
33
39
  /** The counter every write revision belongs to; a new generation restarts all counters. */
34
40
  const epochId = '$epoch';
41
+ /**
42
+ * Joins a counter's id and the suffix of one of its entries. No counter id
43
+ * contains it, so a counter's base and entries are exactly the ids from the
44
+ * counter's own up to, not including, the id followed by `entryRangeEnd`.
45
+ */
46
+ const entrySeparator = '\u0000';
47
+ const entryRangeEnd = '\u0001';
48
+ /** Entries one process adds to a counter before it folds the counter's entries into its base. */
49
+ const compactionEntries = 64;
50
+ const maximumCompactedEntries = 1024;
51
+ const compactionTimeoutMs = 5_000;
35
52
  const maximumPartitionLength = 512;
36
53
  const maximumPinnedPartitions = 64;
37
54
  /** Beyond this many documents, a write by selector bumps the shared counter instead. */
@@ -68,7 +85,10 @@ export const isSmartdataWriteRevisionsActive = (dbArg: SmartdataDb): boolean =>
68
85
 
69
86
  /** A string the counters can name, or `undefined`. */
70
87
  const partitionOf = (valueArg: unknown): string | undefined =>
71
- typeof valueArg === 'string' && valueArg.length <= maximumPartitionLength && valueArg.isWellFormed()
88
+ typeof valueArg === 'string'
89
+ && valueArg.length <= maximumPartitionLength
90
+ && valueArg.isWellFormed()
91
+ && !valueArg.includes(entrySeparator)
72
92
  ? valueArg
73
93
  : undefined;
74
94
 
@@ -145,6 +165,51 @@ const epochKey = 'e';
145
165
  const counterKey = (idArg: string): string => `c${idArg}`;
146
166
  const rangeKey = (collectionArg: string): string => `r${collectionArg}`;
147
167
 
168
+ interface ILocalRevisionSnapshot {
169
+ keys: readonly string[];
170
+ lost: boolean;
171
+ }
172
+
173
+ interface ILocalRevisionCommits {
174
+ pending: Set<ReadonlySet<string>>;
175
+ snapshots: Set<ILocalRevisionSnapshot>;
176
+ }
177
+
178
+ const localRevisionCommits = new WeakMap<SmartdataDb, ILocalRevisionCommits>();
179
+
180
+ const revisionCommitsOfDb = (dbArg: SmartdataDb): ILocalRevisionCommits => {
181
+ let commits = localRevisionCommits.get(dbArg);
182
+ if (!commits) {
183
+ commits = { pending: new Set(), snapshots: new Set() };
184
+ localRevisionCommits.set(dbArg, commits);
185
+ }
186
+ return commits;
187
+ };
188
+
189
+ export const writeRevisionBumpsOfKey = (keyArg: string, idsArg: ReadonlySet<string>): number => {
190
+ if (keyArg.startsWith('c')) return idsArg.has(keyArg.slice(1)) ? 1 : 0;
191
+ if (!keyArg.startsWith('r')) return 0;
192
+ const collection = keyArg.slice(1);
193
+ let bumps = 0;
194
+ for (const id of idsArg) {
195
+ if (id >= `${collection}$` && id < `${collection}%`) bumps++;
196
+ }
197
+ return bumps;
198
+ };
199
+
200
+ export const beginSmartdataWriteRevisionCommit = (
201
+ dbArg: SmartdataDb,
202
+ idsArg: ReadonlySet<string>,
203
+ ): (() => void) => {
204
+ const commits = revisionCommitsOfDb(dbArg);
205
+ const ids = new Set(idsArg);
206
+ commits.pending.add(ids);
207
+ for (const snapshot of commits.snapshots) {
208
+ if (snapshot.keys.some((keyArg) => writeRevisionBumpsOfKey(keyArg, ids) > 0)) snapshot.lost = true;
209
+ }
210
+ return () => { commits.pending.delete(ids); };
211
+ };
212
+
148
213
  /**
149
214
  * @internal The snapshot keys one read depends on. A read the field pins
150
215
  * depends on its partitions and the shared counter, which holds every
@@ -183,9 +248,10 @@ const connectedDb = (dbArg: SmartdataDb): plugins.mongodb.Db => {
183
248
  /**
184
249
  * @internal The current value of each snapshot key, or `undefined` for a key
185
250
  * whose value cannot be known: a missing epoch means the counters restarted.
186
- * Bounded by its own deadline.
251
+ * Every counter and collection sum is one read, so a compaction that moves
252
+ * entries into a base never shows half done. Bounded by its own deadline.
187
253
  */
188
- export const readWriteRevisionSnapshot = async (
254
+ const readStoredWriteRevisionSnapshot = async (
189
255
  dbArg: SmartdataDb,
190
256
  keysArg: readonly string[],
191
257
  ): Promise<Map<string, string | undefined>> => {
@@ -197,39 +263,61 @@ export const readWriteRevisionSnapshot = async (
197
263
  timeoutMode: 'cursorLifetime' as const,
198
264
  signal,
199
265
  });
200
- const counterIds = keysArg.flatMap((keyArg) => keyArg.startsWith('c') ? [keyArg.slice(1)] : []);
201
- const ranges = keysArg.flatMap((keyArg) => keyArg.startsWith('r') ? [keyArg.slice(1)] : []);
202
- const [counters, ...sums] = await Promise.all([
203
- collection.find(
204
- { _id: { $in: [epochId, ...counterIds] } },
205
- { projection: { n: 1, generation: 1 }, ...options() },
206
- ).toArray(),
207
- ...ranges.map((collectionArg) => collection.aggregate<{ n: unknown }>([
208
- { $match: { _id: { $gte: `${collectionArg}$`, $lt: `${collectionArg}%` } } },
209
- { $group: { _id: null, n: { $sum: '$n' } } },
210
- ], options()).toArray()),
266
+ // `$sum` skips a value that is not a number, so such values are counted apart.
267
+ const sumOf = async (fromArg: string, toArg: string): Promise<string | undefined> => {
268
+ const [sum] = await collection.aggregate<{ n: unknown; invalid: unknown }>([
269
+ { $match: { _id: { $gte: fromArg, $lt: toArg } } },
270
+ { $group: { _id: null, n: { $sum: '$n' }, invalid: { $sum: { $cond: [{ $isNumber: '$n' }, 0, 1] } } } },
271
+ ], options()).toArray();
272
+ if (sum === undefined) return '0';
273
+ return sum.invalid === 0 && typeof sum.n === 'number' && Number.isSafeInteger(sum.n)
274
+ ? String(sum.n)
275
+ : undefined;
276
+ };
277
+ const sums = keysArg.flatMap((keyArg): [string, Promise<string | undefined>][] => {
278
+ if (keyArg.startsWith('c')) {
279
+ const id = keyArg.slice(1);
280
+ return [[keyArg, sumOf(id, `${id}${entryRangeEnd}`)]];
281
+ }
282
+ if (keyArg.startsWith('r')) {
283
+ const collectionName = keyArg.slice(1);
284
+ return [[keyArg, sumOf(`${collectionName}$`, `${collectionName}%`)]];
285
+ }
286
+ return [];
287
+ });
288
+ const [epoch, ...values] = await Promise.all([
289
+ collection.findOne({ _id: epochId }, { projection: { n: 1, generation: 1 }, ...options() }),
290
+ ...sums.map(([, sumArg]) => sumArg),
211
291
  ]);
212
- const byId = new Map(counters.map((documentArg) => [documentArg._id, documentArg]));
213
292
  const snapshot = new Map<string, string | undefined>();
214
- const epoch = byId.get(epochId);
215
293
  snapshot.set(epochKey, typeof epoch?.generation === 'string' && Number.isSafeInteger(epoch.n)
216
294
  ? `${epoch.generation}:${epoch.n}`
217
295
  : undefined);
218
- for (const id of counterIds) {
219
- const counter = byId.get(id);
220
- snapshot.set(counterKey(id), counter === undefined
221
- ? '0'
222
- : Number.isSafeInteger(counter.n) ? String(counter.n) : undefined);
223
- }
224
- for (const [index, collectionArg] of ranges.entries()) {
225
- const sum = sums[index].length === 0 ? 0 : sums[index][0].n;
226
- snapshot.set(rangeKey(collectionArg), typeof sum === 'number' && Number.isSafeInteger(sum)
227
- ? String(sum)
228
- : undefined);
229
- }
296
+ for (const [index, [key]] of sums.entries()) snapshot.set(key, values[index]);
230
297
  return snapshot;
231
298
  };
232
299
 
300
+ export const readWriteRevisionSnapshot = async (
301
+ dbArg: SmartdataDb,
302
+ keysArg: readonly string[],
303
+ ): Promise<Map<string, string | undefined>> => {
304
+ const commits = revisionCommitsOfDb(dbArg);
305
+ const snapshot: ILocalRevisionSnapshot = {
306
+ keys: keysArg,
307
+ lost: [...commits.pending].some((idsArg) =>
308
+ keysArg.some((keyArg) => writeRevisionBumpsOfKey(keyArg, idsArg) > 0)),
309
+ };
310
+ commits.snapshots.add(snapshot);
311
+ try {
312
+ const stored = await readStoredWriteRevisionSnapshot(dbArg, keysArg);
313
+ return snapshot.lost
314
+ ? new Map(keysArg.map((keyArg) => [keyArg, undefined]))
315
+ : stored;
316
+ } finally {
317
+ commits.snapshots.delete(snapshot);
318
+ }
319
+ };
320
+
233
321
  /**
234
322
  * @internal Creates the epoch of a database that has none, before `init()`
235
323
  * publishes the connection. Another process creating it at the same time is
@@ -276,8 +364,8 @@ export const bumpSmartdataWriteRevisionEpoch = async (
276
364
 
277
365
  /**
278
366
  * @internal Bumps the counters a transaction's writes touched, inside that
279
- * transaction, right before its commit. Sorted, so concurrent transactions
280
- * take the counters in one order.
367
+ * transaction, right before its commit: one new entry per counter, which no
368
+ * concurrent transaction writes.
281
369
  */
282
370
  export const flushSmartdataWriteRevisions = async (
283
371
  mongoDbArg: plugins.mongodb.Db,
@@ -286,8 +374,9 @@ export const flushSmartdataWriteRevisions = async (
286
374
  signalArg: AbortSignal | undefined,
287
375
  ): Promise<void> => {
288
376
  if (idsArg.size === 0) return;
289
- await revisionsCollection(mongoDbArg).bulkWrite([...idsArg].sort().map((idArg) => ({
290
- updateOne: { filter: { _id: idArg }, update: { $inc: { n: 1 } }, upsert: true },
377
+ await revisionsCollection(mongoDbArg).insertMany([...idsArg].map((idArg) => ({
378
+ _id: `${idArg}${entrySeparator}${plugins.nodeCrypto.randomUUID()}`,
379
+ n: 1,
291
380
  })), {
292
381
  ordered: true,
293
382
  session: rawSessionArg,
@@ -295,6 +384,109 @@ export const flushSmartdataWriteRevisions = async (
295
384
  });
296
385
  };
297
386
 
387
+ interface IWriteRevisionCompactions {
388
+ /** Committed entries this process added per counter since it last compacted it. */
389
+ entries: Map<string, number>;
390
+ running: Map<string, Promise<void>>;
391
+ controller: AbortController;
392
+ }
393
+
394
+ const revisionCompactions = new WeakMap<SmartdataDb, IWriteRevisionCompactions>();
395
+
396
+ /**
397
+ * Folds up to `maximumCompactedEntries` entries of one counter into its base
398
+ * in one transaction, which keeps the counter's value. A concurrent compaction
399
+ * of the same counter conflicts on the base and on the entries, and the driver
400
+ * retries it within the deadline; one that still fails leaves the entries for
401
+ * the next.
402
+ */
403
+ const compactWriteRevisionEntries = async (
404
+ dbArg: SmartdataDb,
405
+ idArg: string,
406
+ signalArg: AbortSignal,
407
+ ): Promise<void> => {
408
+ const collection = revisionsCollection(connectedDb(dbArg));
409
+ const abortable: plugins.mongodb.Abortable = { signal: signalArg };
410
+ const session = dbArg.mongoDbClient.startSession();
411
+ try {
412
+ await session.withTransaction(async () => {
413
+ const entries = await collection.find(
414
+ { _id: { $gt: `${idArg}${entrySeparator}`, $lt: `${idArg}${entryRangeEnd}` } },
415
+ { projection: { n: 1 }, limit: maximumCompactedEntries, session, ...abortable },
416
+ ).toArray();
417
+ if (entries.length === 0) return;
418
+ let sum = 0;
419
+ for (const entry of entries) {
420
+ if (!Number.isSafeInteger(entry.n)) {
421
+ throw new SmartdataPersistenceError('invalid_document',
422
+ `Write-revision entry "${entry._id}" holds no safe integer.`);
423
+ }
424
+ sum += entry.n;
425
+ if (!Number.isSafeInteger(sum)) {
426
+ throw new SmartdataPersistenceError('invalid_document',
427
+ `Write-revision entries of "${idArg}" do not sum to a safe integer.`);
428
+ }
429
+ }
430
+ await collection.deleteMany(
431
+ { _id: { $in: entries.map((entryArg) => entryArg._id) } },
432
+ { session, ...abortable },
433
+ );
434
+ await collection.updateOne(
435
+ { _id: idArg },
436
+ { $inc: { n: sum } },
437
+ { upsert: true, session, ...abortable },
438
+ );
439
+ }, { timeoutMS: compactionTimeoutMs });
440
+ } finally {
441
+ await session.endSession();
442
+ }
443
+ };
444
+
445
+ /**
446
+ * @internal Counts the entries a committed transaction of this process added,
447
+ * and starts a compaction of each counter that reached `compactionEntries`.
448
+ * Compactions run in the background; `stopSmartdataWriteRevisionCompactions`
449
+ * ends them.
450
+ */
451
+ export const noteSmartdataWriteRevisionEntries = (
452
+ dbArg: SmartdataDb,
453
+ idsArg: ReadonlySet<string>,
454
+ ): void => {
455
+ if (idsArg.size === 0 || dbArg.status !== 'connected' || !isSmartdataWriteRevisionsActive(dbArg)) return;
456
+ let state = revisionCompactions.get(dbArg);
457
+ if (!state) {
458
+ state = { entries: new Map(), running: new Map(), controller: new AbortController() };
459
+ revisionCompactions.set(dbArg, state);
460
+ }
461
+ const compactions = state;
462
+ for (const id of idsArg) {
463
+ const entries = (compactions.entries.get(id) ?? 0) + 1;
464
+ if (entries < compactionEntries || compactions.running.has(id)) {
465
+ compactions.entries.set(id, entries);
466
+ continue;
467
+ }
468
+ compactions.entries.delete(id);
469
+ const signal = compactions.controller.signal;
470
+ const compaction = compactWriteRevisionEntries(dbArg, id, signal)
471
+ .catch((errorArg) => {
472
+ if (signal.aborted) return;
473
+ logger.log('warn', `Compacting the write revisions of "${id}" failed; a later compaction retries: ${
474
+ errorArg instanceof Error ? errorArg.message : String(errorArg)}`);
475
+ })
476
+ .finally(() => { compactions.running.delete(id); });
477
+ compactions.running.set(id, compaction);
478
+ }
479
+ };
480
+
481
+ /** @internal Aborts the database's compactions and waits until they settled; `close()` calls it. */
482
+ export const stopSmartdataWriteRevisionCompactions = async (dbArg: SmartdataDb): Promise<void> => {
483
+ const state = revisionCompactions.get(dbArg);
484
+ if (!state) return;
485
+ revisionCompactions.delete(dbArg);
486
+ state.controller.abort();
487
+ await Promise.allSettled(state.running.values());
488
+ };
489
+
298
490
  /**
299
491
  * @internal The counters of the documents a write by selector is about to
300
492
  * change, read in its transaction before it runs. Snapshot isolation makes
@@ -32,6 +32,12 @@ export const unidentifiedSmartdataEngine: ISmartdataEngineIdentity = Object.free
32
32
  */
33
33
  const compositeGroupingEngineRelease = '10.5.0';
34
34
 
35
+ /**
36
+ * The NoSQLDB release that publishes its durable commit journal through the
37
+ * `getCommittedWrites` command: @lossless.org/nosqldb 10.9.0.
38
+ */
39
+ const commitJournalEngineRelease = '10.9.0';
40
+
35
41
  const parseEngineVersion = (versionArg: string): readonly number[] | undefined => {
36
42
  const parts = versionArg.split('.');
37
43
  if (parts.length !== 3) {
@@ -91,6 +97,16 @@ export const smartdataCompositeGrouping = (
91
97
  }
92
98
  };
93
99
 
100
+ /**
101
+ * Whether the identified engine publishes a durable, commit-ordered journal of
102
+ * the transactions it committed. Only NoSQLDB does, from 10.9.0 on; MongoDB's
103
+ * change streams are a different contract and are not used for it.
104
+ */
105
+ export const smartdataCommitJournal = (identityArg: ISmartdataEngineIdentity): boolean =>
106
+ identityArg.product === 'NoSQLDB'
107
+ && identityArg.version !== null
108
+ && engineVersionReaches(identityArg.version, commitJournalEngineRelease);
109
+
94
110
  export const composeSmartdataCapabilities = (
95
111
  backendArg: TSmartdataEngineBackend,
96
112
  compositeGroupingArg: TCompositeGrouping,