@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
@@ -0,0 +1,701 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ import type { SmartdataDb } from './classes.db.js';
4
+ import { assertDataOptions } from './classes.operationbudget.js';
5
+ import { SmartdataPersistenceError } from './classes.persistence.js';
6
+ import {
7
+ narrowSmartdataWriteReport,
8
+ normalizeObservedFields,
9
+ reportOfSmartdataWrite,
10
+ type ISmartdataWrite,
11
+ type ISmartdataWriteReport,
12
+ } from './classes.writeobserver.js';
13
+ import { isSmartdataWriteRevisionsActive, writeRevisionsCollectionName } from './classes.writerevisions.js';
14
+ import { smartdataCommitJournal } from './engineidentity.js';
15
+ import { logger } from './logging.js';
16
+
17
+ /**
18
+ * The write log lets a process follow the model writes of every process in
19
+ * commit order. Every transaction that writes through the models adds one
20
+ * entry to `__smartdata_write_log` right before its commit: the observed
21
+ * fields of its writes, keyed by the transaction's commit receipt. The
22
+ * engine's commit journal lists each committed transaction with that receipt,
23
+ * so a follower reads the journal in commit order and joins every commit to
24
+ * its entry. A commit without an entry — a raw driver write, a migration, an
25
+ * entry the retention removed — still names its collections, which the
26
+ * follower reports as changed in ways no report describes.
27
+ */
28
+ export interface ISmartdataWriteLogOptions {
29
+ /**
30
+ * Top-level field names the log records of every write: 1 to 64 distinct
31
+ * names, no `.` and no leading `$`. A follower follows some or all of them.
32
+ */
33
+ fields: readonly string[];
34
+ }
35
+
36
+ /** A commit's position in the engine's commit journal. */
37
+ export interface ISmartdataCommitStamp {
38
+ /** The journal's timeline; a restart of a memory-backed engine starts a new one. */
39
+ readonly timeline: string;
40
+ /** The commit's position on the timeline, an exact unsigned decimal. */
41
+ readonly sequence: string;
42
+ }
43
+
44
+ /** One committed transaction, delivered once to every follower of its database. */
45
+ export interface ISmartdataFollowedWrite {
46
+ /**
47
+ * `local` for a transaction of this database instance, delivered as soon as
48
+ * its commit was confirmed; `journal` for one read from the commit journal.
49
+ */
50
+ readonly origin: 'local' | 'journal';
51
+ /** The commit's journal position; `null` for a local delivery. */
52
+ readonly stamp: ISmartdataCommitStamp | null;
53
+ /** When the engine committed it; `null` for a local delivery. */
54
+ readonly committedAtMs: number | null;
55
+ /** The transaction's model writes, with the follower's fields. */
56
+ readonly reports: readonly ISmartdataWriteReport[];
57
+ /** Collections the transaction changed in ways no report describes. */
58
+ readonly unplaced: readonly string[];
59
+ }
60
+
61
+ /**
62
+ * Why a follower lost its place in the journal: the journal no longer
63
+ * continues the history the follower read, as after a restart of a
64
+ * memory-backed engine, or it no longer holds every commit since the
65
+ * follower's position.
66
+ */
67
+ export type TSmartdataWriteGapReason = 'timeline' | 'retention';
68
+
69
+ export interface ISmartdataWriteGap {
70
+ readonly reason: TSmartdataWriteGapReason;
71
+ }
72
+
73
+ export interface ISmartdataWriteFollowerOptions {
74
+ /** Fields of the database's write log the deliveries carry. */
75
+ fields: readonly string[];
76
+ /**
77
+ * Called synchronously once per committed transaction. A listener that
78
+ * throws is logged and the follower moves on.
79
+ */
80
+ onWrite: (writeArg: ISmartdataFollowedWrite) => void;
81
+ /**
82
+ * Called when commits may have been missed. The follower has already moved
83
+ * to the journal's head, so state rebuilt from the database now misses
84
+ * nothing later deliveries do not report.
85
+ */
86
+ onGap: (gapArg: ISmartdataWriteGap) => void;
87
+ }
88
+
89
+ export interface ISmartdataWriteFollower {
90
+ /** Stops the follower and waits until its last delivery returned. */
91
+ stop(): Promise<void>;
92
+ }
93
+
94
+ /** The collection that holds the entries. */
95
+ export const writeLogCollectionName = '__smartdata_write_log';
96
+
97
+ /** How long an entry is kept. The engine keeps a commit in its journal at most as long. */
98
+ export const writeLogRetentionSeconds = 15 * 60;
99
+ const expiryIndexName = 'smartdata_write_log_expiry';
100
+ const activePollMs = 250;
101
+ const idlePollMs = 1_000;
102
+ /** A follower that saw a commit this recently polls at the active pace. */
103
+ const activeWindowMs = 60_000;
104
+ const pageLimit = 128;
105
+ /** The engine's largest page; a single commit record larger than a page is refused. */
106
+ const pageBytes = 4 * 1024 * 1024;
107
+ const readTimeoutMs = 5_000;
108
+ /** Beyond these bounds, an entry states its largest collections as unplaced instead. */
109
+ const maximumEntryDocuments = 4_096;
110
+ const maximumEntryBytes = 1024 * 1024;
111
+ /** How long a follower remembers a local delivery whose commit the journal has not listed yet. */
112
+ const localDeliveryMemoryMs = 2 * writeLogRetentionSeconds * 1_000;
113
+ /**
114
+ * Namespaces whose changes only carry the bookkeeping of model writes, and the
115
+ * database's own namespace, which a catalog change publishes beside the
116
+ * collection it changed.
117
+ */
118
+ const bookkeepingCollections: ReadonlySet<string> = new Set([
119
+ '',
120
+ writeLogCollectionName,
121
+ writeRevisionsCollectionName,
122
+ ]);
123
+ const writeKinds: ReadonlySet<unknown> = new Set(['insert', 'update', 'replace', 'delete']);
124
+
125
+ interface IWriteLogEntry {
126
+ readonly reports: readonly ISmartdataWriteReport[];
127
+ readonly unplaced: readonly string[];
128
+ }
129
+
130
+ interface IWriteLogDocument {
131
+ _id: string;
132
+ at: Date;
133
+ fields: string[];
134
+ reports: ISmartdataWriteReport[];
135
+ unplaced: string[];
136
+ }
137
+
138
+ /** @internal One transaction's entry, written in the transaction, until its outcome settled. */
139
+ export interface ISmartdataWriteLogCommit {
140
+ readonly receipt: string;
141
+ readonly entry: IWriteLogEntry;
142
+ /** Releases the followers that wait for the outcome; called once the outcome is known. */
143
+ settle(): void;
144
+ }
145
+
146
+ interface IWriteLogState {
147
+ followers: Set<SmartdataWriteFollower>;
148
+ /** The transactions of this database instance that wrote an entry and have not settled. */
149
+ pending: Map<string, Promise<void>>;
150
+ }
151
+
152
+ const logFields = new WeakMap<SmartdataDb, readonly string[]>();
153
+ const logStates = new WeakMap<SmartdataDb, IWriteLogState>();
154
+
155
+ const stateOf = (dbArg: SmartdataDb): IWriteLogState => {
156
+ let state = logStates.get(dbArg);
157
+ if (!state) {
158
+ state = { followers: new Set(), pending: new Map() };
159
+ logStates.set(dbArg, state);
160
+ }
161
+ return state;
162
+ };
163
+
164
+ /** @internal Validates and records the option; called once by the database's constructor. */
165
+ export const configureSmartdataWriteLog = (dbArg: SmartdataDb, optionsArg: unknown): void => {
166
+ if (optionsArg === undefined) return;
167
+ const { fields } = assertDataOptions(optionsArg, ['fields']);
168
+ logFields.set(dbArg, normalizeObservedFields(fields, 'A SmartData write log'));
169
+ };
170
+
171
+ /**
172
+ * @internal The write log is configured and the engine journals its commits
173
+ * in commit order; elsewhere the option is inert.
174
+ */
175
+ export const isSmartdataWriteLogActive = (dbArg: SmartdataDb): boolean =>
176
+ logFields.has(dbArg)
177
+ && dbArg.capabilities?.transactions === 'available'
178
+ && smartdataCommitJournal(dbArg.engineIdentity);
179
+
180
+ /**
181
+ * @internal Every model write runs in an owned transaction, which carries its
182
+ * write revisions and its write-log entry.
183
+ */
184
+ export const requiresSmartdataWriteTransactions = (dbArg: SmartdataDb): boolean =>
185
+ isSmartdataWriteRevisionsActive(dbArg) || isSmartdataWriteLogActive(dbArg);
186
+
187
+ /** @internal The report a write adds to its transaction's entry, or `undefined` without an active log. */
188
+ export const writeLogReportOf = (
189
+ dbArg: SmartdataDb,
190
+ writeArg: ISmartdataWrite,
191
+ ): ISmartdataWriteReport | undefined => {
192
+ const fields = logFields.get(dbArg);
193
+ return fields !== undefined && isSmartdataWriteLogActive(dbArg)
194
+ ? reportOfSmartdataWrite(writeArg, fields)
195
+ : undefined;
196
+ };
197
+
198
+ const logCollection = (mongoDbArg: plugins.mongodb.Db) =>
199
+ mongoDbArg.collection<IWriteLogDocument>(writeLogCollectionName);
200
+
201
+ /**
202
+ * @internal Creates the index that expires entries, before `init()` publishes
203
+ * the connection. Another process creating it at the same time is fine.
204
+ */
205
+ export const ensureSmartdataWriteLog = async (
206
+ mongoDbArg: plugins.mongodb.Db,
207
+ optionsArg: { timeoutMS?: number; signal?: AbortSignal } = {},
208
+ ): Promise<void> => {
209
+ await logCollection(mongoDbArg).createIndex({ at: 1 }, {
210
+ name: expiryIndexName,
211
+ expireAfterSeconds: writeLogRetentionSeconds,
212
+ ...optionsArg,
213
+ });
214
+ };
215
+
216
+ /**
217
+ * The receipt the engine journals a transaction's commit with. The driver
218
+ * numbers a transaction when it sends the transaction's first command.
219
+ */
220
+ const receiptOfSession = (rawSessionArg: plugins.mongodb.ClientSession): string => {
221
+ const id = rawSessionArg.id;
222
+ if (id === undefined) {
223
+ throw new SmartdataPersistenceError('unsupported_operation',
224
+ 'A SmartData transaction has no server session to key its write-log entry by.');
225
+ }
226
+ return `${id.id.toUUID().toHexString()}:${rawSessionArg.serverSession.txnNumber}`;
227
+ };
228
+
229
+ const receiptOfJournal = (sessionArg: string, transactionArg: string): string =>
230
+ `${sessionArg}:${transactionArg}`;
231
+
232
+ /**
233
+ * The entry as written: a collection whose writes cannot be placed changed as
234
+ * a whole, so its reports add nothing, and the largest collections become
235
+ * unplaced until the entry fits its bounds.
236
+ */
237
+ const boundWriteLogEntry = (
238
+ reportsArg: readonly ISmartdataWriteReport[],
239
+ unplacedArg: ReadonlySet<string>,
240
+ ): IWriteLogEntry => {
241
+ const unplaced = new Set(unplacedArg);
242
+ const sizes = new Map<string, { documents: number; bytes: number }>();
243
+ let documents = 0;
244
+ let bytes = plugins.mongodb.BSON.calculateObjectSize({ unplaced: [...unplaced] });
245
+ for (const report of reportsArg) {
246
+ if (unplaced.has(report.collection)) continue;
247
+ const size = sizes.get(report.collection) ?? { documents: 0, bytes: 0 };
248
+ const reportDocuments = report.documents?.length ?? 0;
249
+ const reportBytes = plugins.mongodb.BSON.calculateObjectSize(report);
250
+ size.documents += reportDocuments;
251
+ size.bytes += reportBytes;
252
+ documents += reportDocuments;
253
+ bytes += reportBytes;
254
+ sizes.set(report.collection, size);
255
+ }
256
+ const bySize = [...sizes].sort(([, aArg], [, bArg]) => bArg.bytes - aArg.bytes);
257
+ for (const [collection, size] of bySize) {
258
+ if (documents <= maximumEntryDocuments && bytes <= maximumEntryBytes) break;
259
+ unplaced.add(collection);
260
+ documents -= size.documents;
261
+ bytes -= size.bytes;
262
+ }
263
+ return Object.freeze({
264
+ reports: Object.freeze(reportsArg.filter((reportArg) => !unplaced.has(reportArg.collection))),
265
+ unplaced: Object.freeze([...unplaced].sort()),
266
+ });
267
+ };
268
+
269
+ /**
270
+ * @internal Writes a transaction's entry inside that transaction, right
271
+ * before its commit, and registers it until its outcome settled. `undefined`
272
+ * when the transaction wrote nothing through the models.
273
+ */
274
+ export const flushSmartdataWriteLog = async (
275
+ dbArg: SmartdataDb,
276
+ mongoDbArg: plugins.mongodb.Db,
277
+ rawSessionArg: plugins.mongodb.ClientSession,
278
+ reportsArg: readonly ISmartdataWriteReport[],
279
+ unplacedArg: ReadonlySet<string>,
280
+ signalArg: AbortSignal | undefined,
281
+ ): Promise<ISmartdataWriteLogCommit | undefined> => {
282
+ const fields = logFields.get(dbArg);
283
+ if (fields === undefined || (reportsArg.length === 0 && unplacedArg.size === 0)) return undefined;
284
+ const collection = logCollection(mongoDbArg);
285
+ const signalOptions = signalArg === undefined ? {} : { signal: signalArg };
286
+ if (reportsArg.length === 0) {
287
+ // No write of the attempt was acknowledged, so none may have numbered the transaction yet.
288
+ await collection.findOne({ _id: '' }, { projection: { _id: 1 }, session: rawSessionArg, ...signalOptions });
289
+ }
290
+ const receipt = receiptOfSession(rawSessionArg);
291
+ const entry = boundWriteLogEntry(reportsArg, unplacedArg);
292
+ await collection.insertOne({
293
+ _id: receipt,
294
+ at: new Date(),
295
+ fields: [...fields],
296
+ reports: [...entry.reports],
297
+ unplaced: [...entry.unplaced],
298
+ }, { session: rawSessionArg, ...signalOptions });
299
+ const { pending } = stateOf(dbArg);
300
+ const settled = Promise.withResolvers<void>();
301
+ pending.set(receipt, settled.promise);
302
+ let open = true;
303
+ return {
304
+ receipt,
305
+ entry,
306
+ settle: () => {
307
+ if (!open) return;
308
+ open = false;
309
+ if (pending.get(receipt) === settled.promise) pending.delete(receipt);
310
+ settled.resolve();
311
+ },
312
+ };
313
+ };
314
+
315
+ /**
316
+ * @internal Delivers a confirmed commit of this database instance to its
317
+ * followers at once; each then skips the commit when the journal lists it.
318
+ */
319
+ export const deliverSmartdataWriteLogCommit = (
320
+ dbArg: SmartdataDb,
321
+ commitArg: ISmartdataWriteLogCommit,
322
+ ): void => {
323
+ const followers = logStates.get(dbArg)?.followers;
324
+ if (!followers) return;
325
+ for (const follower of [...followers]) {
326
+ follower.deliverLocal(commitArg);
327
+ }
328
+ };
329
+
330
+ interface ICommittedWrite {
331
+ stamp: ISmartdataCommitStamp;
332
+ committedAtMs: number;
333
+ /** The changed collections of the database, bookkeeping left out. */
334
+ collections: string[];
335
+ /** The key of a transaction's receipt; a retryable single statement has none. */
336
+ receipt: string | undefined;
337
+ /**
338
+ * The commit wrote an entry. Writes that changed nothing publish no
339
+ * namespace of their own, so their entry is all the journal shows of them.
340
+ */
341
+ logged: boolean;
342
+ }
343
+
344
+ interface ICommittedWritesPage {
345
+ writes: ICommittedWrite[];
346
+ cursor: ISmartdataCommitStamp;
347
+ head: ISmartdataCommitStamp;
348
+ gap: boolean;
349
+ hasMore: boolean;
350
+ }
351
+
352
+ const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
353
+ if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) return false;
354
+ const prototype = Object.getPrototypeOf(valueArg);
355
+ return prototype === Object.prototype || prototype === null;
356
+ };
357
+
358
+ const unexpectedPage = (): SmartdataPersistenceError =>
359
+ new SmartdataPersistenceError('unsupported_operation',
360
+ 'The engine answered getCommittedWrites with an unexpected page.');
361
+
362
+ const unsignedDecimal = /^(?:0|[1-9][0-9]*)$/;
363
+
364
+ const stampOf = (valueArg: unknown): ISmartdataCommitStamp => {
365
+ if (
366
+ !isPlainRecord(valueArg)
367
+ || typeof valueArg.timeline !== 'string'
368
+ || typeof valueArg.sequence !== 'string'
369
+ || !unsignedDecimal.test(valueArg.sequence)
370
+ ) {
371
+ throw unexpectedPage();
372
+ }
373
+ return Object.freeze({ timeline: valueArg.timeline, sequence: valueArg.sequence });
374
+ };
375
+
376
+ const committedWriteOf = (valueArg: unknown, databaseArg: string): ICommittedWrite => {
377
+ if (!isPlainRecord(valueArg) || !Array.isArray(valueArg.namespaces) || typeof valueArg.committedAtMs !== 'number') {
378
+ throw unexpectedPage();
379
+ }
380
+ const collections = new Set<string>();
381
+ let logged = false;
382
+ for (const namespace of valueArg.namespaces) {
383
+ if (!isPlainRecord(namespace) || typeof namespace.database !== 'string' || typeof namespace.collection !== 'string') {
384
+ throw unexpectedPage();
385
+ }
386
+ if (namespace.database !== databaseArg) continue;
387
+ if (namespace.collection === writeLogCollectionName) logged = true;
388
+ if (!bookkeepingCollections.has(namespace.collection)) collections.add(namespace.collection);
389
+ }
390
+ const receipt = valueArg.receipt;
391
+ if (receipt !== null && receipt !== undefined && (
392
+ !isPlainRecord(receipt) || typeof receipt.session !== 'string' || typeof receipt.transaction !== 'string'
393
+ )) {
394
+ throw unexpectedPage();
395
+ }
396
+ return {
397
+ stamp: stampOf(valueArg.stamp),
398
+ committedAtMs: valueArg.committedAtMs,
399
+ collections: [...collections].sort(),
400
+ // A retryable statement outside a transaction carries its number; only a
401
+ // transaction can have written an entry.
402
+ receipt: isPlainRecord(receipt) && (receipt.statement === null || receipt.statement === undefined)
403
+ ? receiptOfJournal(receipt.session as string, receipt.transaction as string)
404
+ : undefined,
405
+ logged,
406
+ };
407
+ };
408
+
409
+ /** One page of the database's commit journal after `afterArg`, or its head without `afterArg`. */
410
+ const readCommittedWrites = async (
411
+ mongoDbArg: plugins.mongodb.Db,
412
+ afterArg: ISmartdataCommitStamp | undefined,
413
+ signalArg?: AbortSignal,
414
+ ): Promise<ICommittedWritesPage> => {
415
+ const page = await mongoDbArg.command({
416
+ getCommittedWrites: 1,
417
+ ...(afterArg === undefined ? {} : { after: { timeline: afterArg.timeline, sequence: afterArg.sequence } }),
418
+ limit: pageLimit,
419
+ maximumBytes: pageBytes,
420
+ }, { timeoutMS: readTimeoutMs, ...(signalArg === undefined ? {} : { signal: signalArg }) });
421
+ if (!Array.isArray(page.writes) || typeof page.gap !== 'boolean' || typeof page.hasMore !== 'boolean') {
422
+ throw unexpectedPage();
423
+ }
424
+ return {
425
+ writes: page.writes.map((writeArg: unknown) => committedWriteOf(writeArg, mongoDbArg.databaseName)),
426
+ cursor: stampOf(page.cursor),
427
+ head: stampOf(page.head),
428
+ gap: page.gap,
429
+ hasMore: page.hasMore,
430
+ };
431
+ };
432
+
433
+ /**
434
+ * The entry a stored document states for a follower of `fieldsArg`, or
435
+ * `undefined` when it states none: a document another version wrote or one
436
+ * that does not record every field followed.
437
+ */
438
+ const entryOfDocument = (documentArg: unknown, fieldsArg: readonly string[]): IWriteLogEntry | undefined => {
439
+ if (!isPlainRecord(documentArg)) return undefined;
440
+ const { fields, reports, unplaced } = documentArg;
441
+ if (
442
+ !Array.isArray(fields)
443
+ || !fieldsArg.every((fieldArg) => fields.includes(fieldArg))
444
+ || !Array.isArray(unplaced)
445
+ || !unplaced.every((collectionArg) => typeof collectionArg === 'string')
446
+ || !Array.isArray(reports)
447
+ || !reports.every((reportArg) =>
448
+ isPlainRecord(reportArg)
449
+ && typeof reportArg.collection === 'string'
450
+ && writeKinds.has(reportArg.kind)
451
+ && isPlainRecord(reportArg.anchors)
452
+ && (reportArg.documents === null
453
+ || (Array.isArray(reportArg.documents) && reportArg.documents.every(isPlainRecord))))
454
+ ) {
455
+ return undefined;
456
+ }
457
+ return { reports: reports as ISmartdataWriteReport[], unplaced: unplaced as string[] };
458
+ };
459
+
460
+ const describeError = (errorArg: unknown): string =>
461
+ errorArg instanceof Error ? errorArg.message : String(errorArg);
462
+
463
+ /** Resolves after `millisecondsArg`, or at once when `signalArg` aborts. */
464
+ const pause = (millisecondsArg: number, signalArg: AbortSignal): Promise<void> =>
465
+ new Promise((resolveArg) => {
466
+ if (signalArg.aborted) return resolveArg();
467
+ const done = () => {
468
+ clearTimeout(timer);
469
+ signalArg.removeEventListener('abort', done);
470
+ resolveArg();
471
+ };
472
+ const timer = setTimeout(done, millisecondsArg);
473
+ signalArg.addEventListener('abort', done, { once: true });
474
+ });
475
+
476
+ /** Resolves when `promiseArg` settles, or at once when `signalArg` aborts. */
477
+ const settledOrAborted = (promiseArg: Promise<void>, signalArg: AbortSignal): Promise<void> =>
478
+ new Promise((resolveArg) => {
479
+ if (signalArg.aborted) return resolveArg();
480
+ const done = () => {
481
+ signalArg.removeEventListener('abort', done);
482
+ resolveArg();
483
+ };
484
+ signalArg.addEventListener('abort', done, { once: true });
485
+ void promiseArg.finally(done);
486
+ });
487
+
488
+ class SmartdataWriteFollower {
489
+ /** The receipts of local deliveries, by when they were delivered, until the journal lists them. */
490
+ private readonly delivered = new Map<string, number>();
491
+ private readonly controller = new AbortController();
492
+ private running: Promise<void> = Promise.resolve();
493
+ private stopping?: Promise<void>;
494
+
495
+ constructor(
496
+ private readonly state: IWriteLogState,
497
+ private readonly fields: readonly string[],
498
+ private readonly onWrite: ISmartdataWriteFollowerOptions['onWrite'],
499
+ private readonly onGap: ISmartdataWriteFollowerOptions['onGap'],
500
+ ) {}
501
+
502
+ public start(mongoDbArg: plugins.mongodb.Db, cursorArg: ISmartdataCommitStamp): void {
503
+ this.state.followers.add(this);
504
+ this.running = this.follow(mongoDbArg, cursorArg);
505
+ }
506
+
507
+ public stop(): Promise<void> {
508
+ this.stopping ??= (async () => {
509
+ this.state.followers.delete(this);
510
+ this.controller.abort();
511
+ await this.running;
512
+ })();
513
+ return this.stopping;
514
+ }
515
+
516
+ public deliverLocal(commitArg: ISmartdataWriteLogCommit): void {
517
+ if (this.controller.signal.aborted) return;
518
+ this.delivered.set(commitArg.receipt, Date.now());
519
+ this.deliver('local', null, null, commitArg.entry.reports, commitArg.entry.unplaced);
520
+ }
521
+
522
+ private deliver(
523
+ originArg: ISmartdataFollowedWrite['origin'],
524
+ stampArg: ISmartdataCommitStamp | null,
525
+ committedAtMsArg: number | null,
526
+ reportsArg: readonly ISmartdataWriteReport[],
527
+ unplacedArg: readonly string[],
528
+ ): void {
529
+ const write: ISmartdataFollowedWrite = Object.freeze({
530
+ origin: originArg,
531
+ stamp: stampArg,
532
+ committedAtMs: committedAtMsArg,
533
+ reports: Object.freeze(reportsArg.map((reportArg) => narrowSmartdataWriteReport(reportArg, this.fields))),
534
+ unplaced: Object.freeze([...unplacedArg]),
535
+ });
536
+ try {
537
+ this.onWrite(write);
538
+ } catch (errorArg) {
539
+ logger.log('error', `A SmartData write follower threw on a commit: ${describeError(errorArg)}`);
540
+ }
541
+ }
542
+
543
+ private reportGap(reasonArg: TSmartdataWriteGapReason): void {
544
+ try {
545
+ this.onGap(Object.freeze({ reason: reasonArg }));
546
+ } catch (errorArg) {
547
+ logger.log('error', `A SmartData write follower threw on a gap: ${describeError(errorArg)}`);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Reads the journal from `cursorArg` on until stopped. A failed read is
553
+ * retried from the same position; a position the journal lost reports a gap
554
+ * and continues from its head.
555
+ */
556
+ private async follow(mongoDbArg: plugins.mongodb.Db, cursorArg: ISmartdataCommitStamp): Promise<void> {
557
+ const signal = this.controller.signal;
558
+ let cursor = cursorArg;
559
+ let lastCommitAt = Number.NEGATIVE_INFINITY;
560
+ let failing = false;
561
+ while (!signal.aborted) {
562
+ try {
563
+ let page: ICommittedWritesPage;
564
+ do {
565
+ page = await readCommittedWrites(mongoDbArg, cursor, signal);
566
+ if (page.gap) {
567
+ // A position beyond the head belongs to a history the journal no longer continues.
568
+ const reason: TSmartdataWriteGapReason = page.head.timeline !== cursor.timeline
569
+ || BigInt(cursor.sequence) > BigInt(page.head.sequence) ? 'timeline' : 'retention';
570
+ cursor = (await readCommittedWrites(mongoDbArg, undefined, signal)).cursor;
571
+ this.reportGap(reason);
572
+ break;
573
+ }
574
+ const committed = await this.deliverPage(mongoDbArg, page.writes, signal);
575
+ if (signal.aborted) return;
576
+ if (committed) lastCommitAt = performance.now();
577
+ cursor = page.cursor;
578
+ } while (page.hasMore && !signal.aborted);
579
+ if (failing) {
580
+ failing = false;
581
+ logger.log('info', `A SmartData write follower of "${mongoDbArg.databaseName}" reads the commit journal again.`);
582
+ }
583
+ } catch (errorArg) {
584
+ if (signal.aborted) return;
585
+ if (!failing) {
586
+ failing = true;
587
+ logger.log('warn', `A SmartData write follower of "${mongoDbArg.databaseName}" failed to read the commit journal and retries: ${describeError(errorArg)}`);
588
+ }
589
+ }
590
+ this.forgetOldDeliveries();
591
+ await pause(performance.now() - lastCommitAt < activeWindowMs ? activePollMs : idlePollMs, signal);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Delivers one page's commits in order and returns whether it held any.
597
+ * A commit of this database instance still settling is waited for, so the
598
+ * follower knows whether it was delivered locally. Every read happens before
599
+ * the first delivery, so a failed read delivers nothing and is retried with
600
+ * the page.
601
+ */
602
+ private async deliverPage(
603
+ mongoDbArg: plugins.mongodb.Db,
604
+ writesArg: readonly ICommittedWrite[],
605
+ signalArg: AbortSignal,
606
+ ): Promise<boolean> {
607
+ const writes = writesArg.filter((writeArg) =>
608
+ writeArg.collections.length > 0 || (writeArg.logged && writeArg.receipt !== undefined));
609
+ if (writes.length === 0) return false;
610
+ const receipts = writes.flatMap((writeArg) => writeArg.receipt === undefined ? [] : [writeArg.receipt]);
611
+ for (const receipt of receipts) {
612
+ const pending = this.state.pending.get(receipt);
613
+ if (pending) await settledOrAborted(pending, signalArg);
614
+ }
615
+ if (signalArg.aborted) return true;
616
+ const lookups = receipts.filter((receiptArg) => !this.delivered.has(receiptArg));
617
+ const documents = new Map<unknown, unknown>();
618
+ if (lookups.length > 0) {
619
+ const found = await logCollection(mongoDbArg).find(
620
+ { _id: { $in: lookups } },
621
+ { projection: { at: 0 }, timeoutMS: readTimeoutMs, signal: signalArg },
622
+ ).toArray();
623
+ for (const document of found) documents.set(document._id, document);
624
+ }
625
+ for (const write of writes) {
626
+ if (signalArg.aborted) return true;
627
+ if (write.receipt !== undefined && this.delivered.delete(write.receipt)) continue;
628
+ const entry = write.receipt === undefined
629
+ ? undefined
630
+ : entryOfDocument(documents.get(write.receipt), this.fields);
631
+ const unplaced = new Set(entry?.unplaced);
632
+ const covered = new Set([...unplaced, ...(entry?.reports ?? []).map((reportArg) => reportArg.collection)]);
633
+ for (const collection of write.collections) {
634
+ if (!covered.has(collection)) unplaced.add(collection);
635
+ }
636
+ this.deliver('journal', write.stamp, write.committedAtMs, entry?.reports ?? [], [...unplaced].sort());
637
+ }
638
+ return true;
639
+ }
640
+
641
+ private forgetOldDeliveries(): void {
642
+ const oldest = Date.now() - localDeliveryMemoryMs;
643
+ for (const [receipt, deliveredAt] of this.delivered) {
644
+ if (deliveredAt < oldest) this.delivered.delete(receipt);
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * @internal Starts a follower at the journal's head; `db.followWrites()`
651
+ * documents it.
652
+ */
653
+ export const followSmartdataWrites = async (
654
+ dbArg: SmartdataDb,
655
+ optionsArg: unknown,
656
+ ): Promise<ISmartdataWriteFollower> => {
657
+ const { fields, onWrite, onGap } = assertDataOptions(optionsArg, ['fields', 'onWrite', 'onGap']);
658
+ const followed = normalizeObservedFields(fields, 'A SmartData write follower');
659
+ if (typeof onWrite !== 'function' || typeof onGap !== 'function') {
660
+ throw new SmartdataPersistenceError('invalid_argument',
661
+ 'A SmartData write follower requires onWrite and onGap functions.');
662
+ }
663
+ const logged = logFields.get(dbArg);
664
+ if (logged === undefined) {
665
+ throw new SmartdataPersistenceError('unsupported_operation',
666
+ 'Following writes requires the writeLog option of the SmartdataDb.');
667
+ }
668
+ if (!followed.every((fieldArg) => logged.includes(fieldArg))) {
669
+ throw new SmartdataPersistenceError('invalid_argument',
670
+ 'A SmartData write follower follows fields of the write log only.');
671
+ }
672
+ const mongoDb = dbArg.mongoDb;
673
+ if (dbArg.status !== 'connected' || !mongoDb) {
674
+ throw new SmartdataPersistenceError('unsupported_operation',
675
+ 'Following writes requires a connected SmartdataDb.');
676
+ }
677
+ if (!isSmartdataWriteLogActive(dbArg)) {
678
+ throw new SmartdataPersistenceError('unsupported_operation',
679
+ 'Following writes requires NoSQLDB 10.9.0 or newer, whose commit journal orders the commits.');
680
+ }
681
+ const tail = await readCommittedWrites(mongoDb, undefined);
682
+ if (dbArg.status !== 'connected' || dbArg.mongoDb !== mongoDb) {
683
+ throw new SmartdataPersistenceError('unsupported_operation',
684
+ 'The SmartdataDb closed while the write follower started.');
685
+ }
686
+ const follower = new SmartdataWriteFollower(
687
+ stateOf(dbArg),
688
+ followed,
689
+ onWrite as ISmartdataWriteFollowerOptions['onWrite'],
690
+ onGap as ISmartdataWriteFollowerOptions['onGap'],
691
+ );
692
+ follower.start(mongoDb, tail.cursor);
693
+ return Object.freeze({ stop: () => follower.stop() });
694
+ };
695
+
696
+ /** @internal Stops every follower of the database; `close()` calls it. */
697
+ export const stopSmartdataWriteFollowers = async (dbArg: SmartdataDb): Promise<void> => {
698
+ const followers = logStates.get(dbArg)?.followers;
699
+ if (!followers) return;
700
+ await Promise.all([...followers].map((followerArg) => followerArg.stop()));
701
+ };