@lossless.org/client 1.22.2 → 1.23.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 (32) 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 +41 -1
  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.session.d.ts +7 -6
  11. package/dist_ts/nosqldb/classes.session.js +88 -36
  12. package/dist_ts/nosqldb/classes.writelog.d.ts +129 -0
  13. package/dist_ts/nosqldb/classes.writelog.js +497 -0
  14. package/dist_ts/nosqldb/classes.writeobserver.d.ts +13 -0
  15. package/dist_ts/nosqldb/classes.writeobserver.js +47 -25
  16. package/dist_ts/nosqldb/classes.writerevisions.d.ts +40 -4
  17. package/dist_ts/nosqldb/classes.writerevisions.js +174 -37
  18. package/dist_ts/nosqldb/engineidentity.d.ts +6 -0
  19. package/dist_ts/nosqldb/engineidentity.js +14 -1
  20. package/package.json +2 -2
  21. package/readme.md +11 -10
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/nosqldb/classes.bootstraprecord.ts +3 -3
  24. package/ts/nosqldb/classes.connection.ts +5 -1
  25. package/ts/nosqldb/classes.db.ts +64 -0
  26. package/ts/nosqldb/classes.doc.ts +4 -4
  27. package/ts/nosqldb/classes.exactpersistence.ts +8 -7
  28. package/ts/nosqldb/classes.session.ts +107 -38
  29. package/ts/nosqldb/classes.writelog.ts +701 -0
  30. package/ts/nosqldb/classes.writeobserver.ts +62 -28
  31. package/ts/nosqldb/classes.writerevisions.ts +225 -41
  32. 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)}`);
@@ -10,6 +10,13 @@ import { SmartdataPersistenceError, isMongoDuplicateKeyError } from './classes.p
10
10
  * no writes at all. Every model write bumps a counter per collection and
11
11
  * partition in the same transaction, and a read set compares the counters it
12
12
  * read before its reads with the current ones.
13
+ *
14
+ * A counter is its base document, which a writer bumps alone, and up to
15
+ * `counterShards - 1` shard documents that overlapping transactions bump
16
+ * instead, so they do not conflict on the base. A list document beside the
17
+ * base holds how many slots are in use, and the transaction that first bumps
18
+ * a slot raises it, so a snapshot reads the bases and lists in one query and
19
+ * only the listed shards of the counters that have any.
13
20
  */
14
21
  export interface ISmartdataWriteRevisionsOptions {
15
22
  /**
@@ -28,10 +35,21 @@ interface IWriteRevisionDocument {
28
35
  _id: string;
29
36
  n: number;
30
37
  generation?: string;
38
+ /** On a counter's list: the slots in use, the base included. */
39
+ slots?: number;
31
40
  }
32
41
 
33
42
  /** The counter every write revision belongs to; a new generation restarts all counters. */
34
43
  const epochId = '$epoch';
44
+ /** Joins a counter's id and the slot of one of its shards, or ends the id of its list. No counter id contains it. */
45
+ const shardSeparator = '\u0000';
46
+ /**
47
+ * Slots per counter, the base included: as many transactions that overlap in
48
+ * time can bump one counter without conflicting on it.
49
+ */
50
+ const counterShards = 16;
51
+ /** Counters whose slots a database instance remembers, beyond those an open transaction holds. */
52
+ const maximumRememberedCounters = 4096;
35
53
  const maximumPartitionLength = 512;
36
54
  const maximumPinnedPartitions = 64;
37
55
  /** Beyond this many documents, a write by selector bumps the shared counter instead. */
@@ -68,7 +86,10 @@ export const isSmartdataWriteRevisionsActive = (dbArg: SmartdataDb): boolean =>
68
86
 
69
87
  /** A string the counters can name, or `undefined`. */
70
88
  const partitionOf = (valueArg: unknown): string | undefined =>
71
- typeof valueArg === 'string' && valueArg.length <= maximumPartitionLength && valueArg.isWellFormed()
89
+ typeof valueArg === 'string'
90
+ && valueArg.length <= maximumPartitionLength
91
+ && valueArg.isWellFormed()
92
+ && !valueArg.includes(shardSeparator)
72
93
  ? valueArg
73
94
  : undefined;
74
95
 
@@ -79,6 +100,13 @@ export const partitionRevisionId = (collectionArg: string, partitionArg: string)
79
100
  /** @internal The counter of a collection's writes that no partition can place. */
80
101
  export const sharedRevisionId = (collectionArg: string): string => `${collectionArg}$*`;
81
102
 
103
+ /** @internal The document of a counter's slot: the base for slot 0, a shard otherwise. */
104
+ export const revisionSlotId = (idArg: string, slotArg: number): string =>
105
+ slotArg === 0 ? idArg : `${idArg}${shardSeparator}${slotArg}`;
106
+
107
+ /** @internal The document that lists how many slots of a counter are in use; none means only the base. */
108
+ export const revisionSlotListId = (idArg: string): string => `${idArg}${shardSeparator}`;
109
+
82
110
  /**
83
111
  * @internal The counter a stored value of the partition field belongs to:
84
112
  * its partition's for a string the counters can name, the shared one otherwise.
@@ -177,17 +205,120 @@ export const writeRevisionBumpsOfKey = (keyArg: string, idsArg: ReadonlySet<stri
177
205
  return bumps;
178
206
  };
179
207
 
180
- export const beginSmartdataWriteRevisionCommit = (
181
- dbArg: SmartdataDb,
182
- idsArg: ReadonlySet<string>,
183
- ): (() => void) => {
184
- const commits = revisionCommitsOfDb(dbArg);
185
- const ids = new Set(idsArg);
186
- commits.pending.add(ids);
187
- for (const snapshot of commits.snapshots) {
188
- if (snapshot.keys.some((keyArg) => writeRevisionBumpsOfKey(keyArg, ids) > 0)) snapshot.lost = true;
208
+ interface IRevisionSlot {
209
+ /** Attempts of this instance that bump the slot and have not ended yet. */
210
+ holders: number;
211
+ /** When the last such attempt ended, on the `performance.now()` clock. */
212
+ endedAt: number;
213
+ }
214
+
215
+ interface IRevisionCounterSlots {
216
+ /** The slot this instance's last committed bump took; a search for a free slot starts there. */
217
+ preferred: number;
218
+ slots: IRevisionSlot[];
219
+ }
220
+
221
+ /** Per database instance, in least recently bumped order. */
222
+ const revisionSlotsOfDbs = new WeakMap<SmartdataDb, Map<string, IRevisionCounterSlots>>();
223
+
224
+ /**
225
+ * The slot of a counter an attempt that started at `attemptStartedAtArg`
226
+ * bumps: from the preferred one on, the first that no attempt of this instance
227
+ * holds or ended since then, whose commit the attempt's snapshot might not
228
+ * see. Without one, the first that none holds, and the preferred one last.
229
+ * Transactions of other instances may still take the same slot; a conflict
230
+ * then fails the attempt, and its retry starts after the slot's end, so it
231
+ * moves on to another.
232
+ */
233
+ const chooseRevisionSlot = (counterArg: IRevisionCounterSlots, attemptStartedAtArg: number): number => {
234
+ let unheld: number | undefined;
235
+ for (let offset = 0; offset < counterShards; offset++) {
236
+ const slot = (counterArg.preferred + offset) % counterShards;
237
+ const state = counterArg.slots[slot];
238
+ if (state.holders > 0) continue;
239
+ if (state.endedAt < attemptStartedAtArg) return slot;
240
+ unheld ??= slot;
189
241
  }
190
- return () => { commits.pending.delete(ids); };
242
+ return unheld ?? counterArg.preferred;
243
+ };
244
+
245
+ /**
246
+ * @internal The slots one attempt of a transaction bumps. A counter takes its
247
+ * slot at its first bump in the attempt and holds it until the attempt ends,
248
+ * so no other attempt of this instance commits to it in between.
249
+ */
250
+ export interface ISmartdataWriteRevisionAttempt {
251
+ /** The slot of each counter the attempt bumps. */
252
+ readonly slots: ReadonlyMap<string, number>;
253
+ /** Takes a slot of a counter the attempt has not bumped yet; once the attempt ended, none. */
254
+ bump(idArg: string): void;
255
+ /**
256
+ * Right before the commit: until the attempt ends, a snapshot of this
257
+ * instance that depends on its counters cannot tell its bumps from those of
258
+ * other processes.
259
+ */
260
+ commit(): void;
261
+ /**
262
+ * Ends the attempt: releases its slots and, after a commit, makes them the
263
+ * counters' preferred ones. Later calls do nothing.
264
+ */
265
+ finish(committedArg: boolean): void;
266
+ }
267
+
268
+ export const beginSmartdataWriteRevisionAttempt = (
269
+ dbArg: SmartdataDb,
270
+ attemptStartedAtArg: number,
271
+ ): ISmartdataWriteRevisionAttempt => {
272
+ const taken: [IRevisionCounterSlots, number][] = [];
273
+ const slots = new Map<string, number>();
274
+ let pending: ReadonlySet<string> | undefined;
275
+ let finished = false;
276
+ return {
277
+ slots,
278
+ bump: (idArg) => {
279
+ if (finished || slots.has(idArg)) return;
280
+ let counters = revisionSlotsOfDbs.get(dbArg);
281
+ if (!counters) {
282
+ counters = new Map();
283
+ revisionSlotsOfDbs.set(dbArg, counters);
284
+ }
285
+ const counter = counters.get(idArg) ?? {
286
+ preferred: 0,
287
+ slots: Array.from({ length: counterShards }, () => ({ holders: 0, endedAt: -Infinity })),
288
+ };
289
+ counters.delete(idArg);
290
+ counters.set(idArg, counter);
291
+ const slot = chooseRevisionSlot(counter, attemptStartedAtArg);
292
+ counter.slots[slot].holders++;
293
+ taken.push([counter, slot]);
294
+ slots.set(idArg, slot);
295
+ for (const [id, remembered] of counters) {
296
+ if (counters.size <= maximumRememberedCounters) break;
297
+ if (remembered.slots.every((slotArg) => slotArg.holders === 0)) counters.delete(id);
298
+ }
299
+ },
300
+ commit: () => {
301
+ if (finished || pending) return;
302
+ const commits = revisionCommitsOfDb(dbArg);
303
+ const ids = new Set(slots.keys());
304
+ pending = ids;
305
+ commits.pending.add(ids);
306
+ for (const snapshot of commits.snapshots) {
307
+ if (snapshot.keys.some((keyArg) => writeRevisionBumpsOfKey(keyArg, ids) > 0)) snapshot.lost = true;
308
+ }
309
+ },
310
+ finish: (committedArg) => {
311
+ if (finished) return;
312
+ finished = true;
313
+ if (pending) revisionCommitsOfDb(dbArg).pending.delete(pending);
314
+ const endedAt = performance.now();
315
+ for (const [counter, slot] of taken) {
316
+ counter.slots[slot].holders--;
317
+ counter.slots[slot].endedAt = endedAt;
318
+ if (committedArg) counter.preferred = slot;
319
+ }
320
+ },
321
+ };
191
322
  };
192
323
 
193
324
  /**
@@ -227,8 +358,12 @@ const connectedDb = (dbArg: SmartdataDb): plugins.mongodb.Db => {
227
358
 
228
359
  /**
229
360
  * @internal The current value of each snapshot key, or `undefined` for a key
230
- * whose value cannot be known: a missing epoch means the counters restarted.
231
- * Bounded by its own deadline.
361
+ * whose value cannot be known: a missing epoch means the counters restarted,
362
+ * and a counter with a value or slot count no writer stores stays unknown. One
363
+ * query reads the epoch, the bases and their lists, a second the listed
364
+ * shards. Every part only grows, and a slot is listed by the transaction that
365
+ * first bumps it, so a write that commits between the two queries shows in
366
+ * this snapshot or in the next, never in neither. Bounded by its own deadline.
232
367
  */
233
368
  const readStoredWriteRevisionSnapshot = async (
234
369
  dbArg: SmartdataDb,
@@ -242,36 +377,59 @@ const readStoredWriteRevisionSnapshot = async (
242
377
  timeoutMode: 'cursorLifetime' as const,
243
378
  signal,
244
379
  });
380
+ const readDocuments = async (idsArg: string[]) => new Map((await collection.find(
381
+ { _id: { $in: idsArg } },
382
+ { projection: { n: 1, generation: 1, slots: 1 }, ...options() },
383
+ ).toArray()).map((documentArg) => [documentArg._id, documentArg]));
384
+ // `$sum` skips a value that is not a number, so such values are counted apart.
385
+ const sumOf = async (collectionArg: string): Promise<string | undefined> => {
386
+ const [sum] = await collection.aggregate<{ n: unknown; invalid: unknown }>([
387
+ { $match: { _id: { $gte: `${collectionArg}$`, $lt: `${collectionArg}%` } } },
388
+ { $group: { _id: null, n: { $sum: '$n' }, invalid: { $sum: { $cond: [{ $isNumber: '$n' }, 0, 1] } } } },
389
+ ], options()).toArray();
390
+ if (sum === undefined) return '0';
391
+ return sum.invalid === 0 && typeof sum.n === 'number' && Number.isSafeInteger(sum.n)
392
+ ? String(sum.n)
393
+ : undefined;
394
+ };
245
395
  const counterIds = keysArg.flatMap((keyArg) => keyArg.startsWith('c') ? [keyArg.slice(1)] : []);
246
396
  const ranges = keysArg.flatMap((keyArg) => keyArg.startsWith('r') ? [keyArg.slice(1)] : []);
247
- const [counters, ...sums] = await Promise.all([
248
- collection.find(
249
- { _id: { $in: [epochId, ...counterIds] } },
250
- { projection: { n: 1, generation: 1 }, ...options() },
251
- ).toArray(),
252
- ...ranges.map((collectionArg) => collection.aggregate<{ n: unknown }>([
253
- { $match: { _id: { $gte: `${collectionArg}$`, $lt: `${collectionArg}%` } } },
254
- { $group: { _id: null, n: { $sum: '$n' } } },
255
- ], options()).toArray()),
397
+ const [bases, ...sums] = await Promise.all([
398
+ readDocuments([epochId, ...counterIds.flatMap((idArg) => [idArg, revisionSlotListId(idArg)])]),
399
+ ...ranges.map(sumOf),
256
400
  ]);
257
- const byId = new Map(counters.map((documentArg) => [documentArg._id, documentArg]));
401
+ /** The slots each counter's list holds, or `undefined` for a count no writer stores. */
402
+ const slotCounts = new Map(counterIds.map((idArg): [string, number | undefined] => {
403
+ const list = bases.get(revisionSlotListId(idArg));
404
+ const count: unknown = list === undefined ? 1 : list.slots;
405
+ return [idArg, Number.isSafeInteger(count) && (count as number) >= 1 && (count as number) <= counterShards
406
+ ? count as number
407
+ : undefined];
408
+ }));
409
+ const shardIds = counterIds.flatMap((idArg) => Array.from(
410
+ { length: (slotCounts.get(idArg) ?? 1) - 1 },
411
+ (_unusedArg, indexArg) => revisionSlotId(idArg, indexArg + 1),
412
+ ));
413
+ const shards = shardIds.length === 0 ? new Map<string, IWriteRevisionDocument>() : await readDocuments(shardIds);
258
414
  const snapshot = new Map<string, string | undefined>();
259
- const epoch = byId.get(epochId);
415
+ const epoch = bases.get(epochId);
260
416
  snapshot.set(epochKey, typeof epoch?.generation === 'string' && Number.isSafeInteger(epoch.n)
261
417
  ? `${epoch.generation}:${epoch.n}`
262
418
  : undefined);
263
419
  for (const id of counterIds) {
264
- const counter = byId.get(id);
265
- snapshot.set(counterKey(id), counter === undefined
266
- ? '0'
267
- : Number.isSafeInteger(counter.n) ? String(counter.n) : undefined);
268
- }
269
- for (const [index, collectionArg] of ranges.entries()) {
270
- const sum = sums[index].length === 0 ? 0 : sums[index][0].n;
271
- snapshot.set(rangeKey(collectionArg), typeof sum === 'number' && Number.isSafeInteger(sum)
272
- ? String(sum)
273
- : undefined);
420
+ const count = slotCounts.get(id);
421
+ let sum: number | undefined = count === undefined ? undefined : 0;
422
+ for (let slot = 0; sum !== undefined && slot < count!; slot++) {
423
+ // A slot is missing until its first bump commits; a later one may commit first.
424
+ const document = slot === 0 ? bases.get(id) : shards.get(revisionSlotId(id, slot));
425
+ if (document === undefined) continue;
426
+ sum = Number.isSafeInteger(document.n) && Number.isSafeInteger(sum + document.n)
427
+ ? sum + document.n
428
+ : undefined;
429
+ }
430
+ snapshot.set(counterKey(id), sum === undefined ? undefined : String(sum));
274
431
  }
432
+ for (const [index, collectionName] of ranges.entries()) snapshot.set(rangeKey(collectionName), sums[index]);
275
433
  return snapshot;
276
434
  };
277
435
 
@@ -342,19 +500,45 @@ export const bumpSmartdataWriteRevisionEpoch = async (
342
500
 
343
501
  /**
344
502
  * @internal Bumps the counters a transaction's writes touched, inside that
345
- * transaction, right before its commit. Sorted, so concurrent transactions
346
- * take the counters in one order.
503
+ * transaction, right before its commit: each in the slot it took. A shard
504
+ * bump first makes sure the counter's list exists and holds the slot; once it
505
+ * does, neither update changes it, so later bumps of listed slots write only
506
+ * their own document. A slot whose value is no number stays as it is, which
507
+ * keeps its counter unknown, and the write still commits.
347
508
  */
348
509
  export const flushSmartdataWriteRevisions = async (
349
510
  mongoDbArg: plugins.mongodb.Db,
350
511
  rawSessionArg: plugins.mongodb.ClientSession,
351
- idsArg: ReadonlySet<string>,
512
+ slotsArg: ReadonlyMap<string, number>,
352
513
  signalArg: AbortSignal | undefined,
353
514
  ): Promise<void> => {
354
- if (idsArg.size === 0) return;
355
- await revisionsCollection(mongoDbArg).bulkWrite([...idsArg].sort().map((idArg) => ({
356
- updateOne: { filter: { _id: idArg }, update: { $inc: { n: 1 } }, upsert: true },
357
- })), {
515
+ if (slotsArg.size === 0) return;
516
+ const operations: plugins.mongodb.AnyBulkWriteOperation<IWriteRevisionDocument>[] = [];
517
+ for (const [id, slot] of [...slotsArg].sort(([aArg], [bArg]) => aArg < bArg ? -1 : aArg > bArg ? 1 : 0)) {
518
+ if (slot > 0) {
519
+ const listId = revisionSlotListId(id);
520
+ operations.push(
521
+ { updateOne: { filter: { _id: listId }, update: { $setOnInsert: { n: 0 } }, upsert: true } },
522
+ {
523
+ updateOne: {
524
+ filter: { _id: listId, $or: [{ slots: { $exists: false } }, { slots: { $lt: slot + 1 } }] },
525
+ update: { $set: { slots: slot + 1 } },
526
+ },
527
+ },
528
+ );
529
+ }
530
+ const slotId = revisionSlotId(id, slot);
531
+ operations.push(
532
+ {
533
+ updateOne: {
534
+ filter: { _id: slotId, $or: [{ n: { $type: 'int' } }, { n: { $type: 'long' } }, { n: { $type: 'double' } }] },
535
+ update: { $inc: { n: 1 } },
536
+ },
537
+ },
538
+ { updateOne: { filter: { _id: slotId }, update: { $setOnInsert: { n: 1 } }, upsert: true } },
539
+ );
540
+ }
541
+ await revisionsCollection(mongoDbArg).bulkWrite(operations, {
358
542
  ordered: true,
359
543
  session: rawSessionArg,
360
544
  ...(signalArg === undefined ? {} : { signal: signalArg }),
@@ -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,