@procedures/mongo 0.34.0 → 0.35.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 (43) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +61 -5
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/index.d.ts +5 -3
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/module.d.ts +2 -2
  9. package/dist/module.d.ts.map +1 -1
  10. package/dist/module.js +4 -5
  11. package/dist/module.js.map +1 -1
  12. package/dist/procedures/input-schemas.d.ts.map +1 -1
  13. package/dist/procedures/input-schemas.js +29 -2
  14. package/dist/procedures/input-schemas.js.map +1 -1
  15. package/dist/storage/document-storage.d.ts +338 -10
  16. package/dist/storage/document-storage.d.ts.map +1 -1
  17. package/dist/storage/document-storage.js +704 -75
  18. package/dist/storage/document-storage.js.map +1 -1
  19. package/dist/storage/index.d.ts +5 -3
  20. package/dist/storage/index.d.ts.map +1 -1
  21. package/dist/storage/index.js +4 -1
  22. package/dist/storage/index.js.map +1 -1
  23. package/dist/storage/materializer.d.ts +27 -12
  24. package/dist/storage/materializer.d.ts.map +1 -1
  25. package/dist/storage/materializer.js +44 -15
  26. package/dist/storage/materializer.js.map +1 -1
  27. package/dist/storage/own-writes.d.ts +51 -0
  28. package/dist/storage/own-writes.d.ts.map +1 -0
  29. package/dist/storage/own-writes.js +130 -0
  30. package/dist/storage/own-writes.js.map +1 -0
  31. package/dist/storage/storage-hints.d.ts +230 -0
  32. package/dist/storage/storage-hints.d.ts.map +1 -0
  33. package/dist/storage/storage-hints.js +876 -0
  34. package/dist/storage/storage-hints.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/index.ts +15 -3
  37. package/src/module.ts +5 -5
  38. package/src/procedures/input-schemas.ts +29 -2
  39. package/src/storage/document-storage.ts +832 -75
  40. package/src/storage/index.ts +15 -3
  41. package/src/storage/materializer.ts +61 -22
  42. package/src/storage/own-writes.ts +157 -0
  43. package/src/storage/storage-hints.ts +971 -0
@@ -0,0 +1,876 @@
1
+ /**
2
+ * The change hints of a `MongoDocumentStorage` collection (roadmap CLI-77). `watchStorageChanges()`
3
+ * watches the change stream of the collection, and publishes each change as a
4
+ * `CollectionChangeEvent` on the topic of the collection procedures. Thus a subscriber also gets
5
+ * the writes that did not go through the procedures: a raw write, a script, another process, a
6
+ * view.
7
+ *
8
+ * - The topic is `collectionChangeTopic(name, partition)`, as the procedures publish it: the
9
+ * partition of a document comes from `storage.form.partitionOf()`.
10
+ * - The event is a hint, without `since` and without `version` (the version of the change log): the
11
+ * subscriber reads the changes with `changes`, as for each event without `since`. The event has
12
+ * the id in `changed` or in `deleted`, and the version of the item in `versions` (a field of
13
+ * `CollectionChangeEvent`): the version that `storage.versionOf(id)` gives after the write.
14
+ * - A write through the storage gives the document a new `_seq`. The watcher reads that from the
15
+ * event, never from the `fullDocument` of the lookup: an update with `_seq` in
16
+ * `updateDescription.updatedFields`, an insert or a replace with `_seq`. With the image before
17
+ * the change, a replace whose `_seq` is the `_seq` before is a raw write (a copy). Without the
18
+ * image, a replace with `_seq` counts as a write of the storage.
19
+ * - A raw write (D39), with `rawWrites: "adopt"` (the default): the watcher publishes nothing for
20
+ * its event. The storage adopts it: one conditional write through the path of each write gives
21
+ * the document a new `_seq` (and the storage fields and partition fields that a raw replace took
22
+ * away), so `changes(since)` has it. The event of the adoption is a write of the storage: its
23
+ * hint goes out, with the new version. When two watchers adopt the same write, or a write of the
24
+ * storage comes first, one write wins and the others do nothing. A raw write never gets the old
25
+ * version. With `rawWrites: "ignore"` (for example for a user that can only read), the hint of a
26
+ * raw write goes out without a version, and `changes(since)` does not have the write.
27
+ * - The adoptions wait in one queue for each watcher: one adoption at a time, and the raw writes
28
+ * that wait together take one block of numbers for each partition. An adoption that fails with
29
+ * an error that can go away tries again after the backoff. When the queue is full, the hint of
30
+ * the next raw write goes out without a version. The watcher keeps the ids of the raw writes that
31
+ * did not fit or that failed (at most 10,000), and adopts them again when the queue is empty,
32
+ * after a sweep. An adoption that fails for good (after its last try, or a second time), or an id
33
+ * over that limit, gives its partition a reset hint (`reset: true`): its subscribers read it in full.
34
+ * - The adoption is exact only with the images before the change (`changeStreamPreAndPostImages`).
35
+ * An insert and a replace show their document, but an update shows only its fields: without the
36
+ * image, the `_seq` that the update kept comes from the lookup. When a write of the storage comes
37
+ * after the raw update, the adoption can then give that write a newer version, and a client that
38
+ * holds the version of that write gets a conflict of its precondition.
39
+ * - The sweep: after each gap (`sweep`, on by default with `rawWrites: "adopt"`), after a full
40
+ * queue, and when the stream opens (`sweepAtOpen`, off by default), the watcher sends each
41
+ * document without `_seq` to the same queue, in pages. Thus a document without `_seq` gets its
42
+ * number. A raw update of a document with `_seq` keeps that `_seq`: no sweep finds it, so a gap
43
+ * publishes a reset hint. The scan walks the index of `_id` and reads each document of the
44
+ * collection: a partial index cannot express `$exists: false`. A document that the sweep cannot
45
+ * place (no id, or another partition) goes to the log once, and the next sweeps leave it.
46
+ * - A raw hard delete of an item: with the image before the change, the storage adopts it as a
47
+ * tombstone. Without the image (no `changeStreamPreAndPostImages` on the collection), the hint
48
+ * goes out without a version, and the delete stays out of `changes(since)`.
49
+ * - A delete of a tombstone (the storage prunes the oldest ones) deletes no item: no hint, no
50
+ * adoption. The image before the change tells it. Without the image, the watcher knows the
51
+ * prunes of this process only.
52
+ * - The writes of this process through the storage: with `ownWrites: "skip"` (the default), the
53
+ * watcher skips their events, because the collection procedures publish them already, with
54
+ * `since`. Thus a subscriber gets one event for each write. With `ownWrites: "publish"`, the
55
+ * watcher publishes each change. Use it when the writes of this process do not go through the
56
+ * procedures, or when the procedures publish no events (`changeEvents: false`). An adoption is
57
+ * never an own write.
58
+ * - After an error of the stream, the watcher opens the stream again after a backoff, from the
59
+ * resume token of the last event: it loses no change. When the server does not have the token
60
+ * any more, or the collection was dropped or renamed, the watcher cannot know what it missed: it
61
+ * publishes a reset hint (a gap: no ids, `reset: true`) on the plain topic and on the topic of
62
+ * each partition that it knows: the partitions of the counters, of `forPartition()`, of the
63
+ * hints, of the subscribed topics of the bus, and of `gapPartitions`. The counters have a floor
64
+ * document for each partition with a write through a storage. The subscribers read the
65
+ * collection in full.
66
+ * - The pipeline of the stream is `[]`: the storage of a partition (`forPartition()`) watches the
67
+ * whole collection and drops the events of other partitions.
68
+ * - Change streams need a replica set or a sharded cluster. The watcher asks the server (`hello`)
69
+ * before the first open: on a single server, `ready` rejects (code 40573).
70
+ */
71
+ import { MongoAPIError, MongoParseError, BSON } from "mongodb";
72
+ import { collectionChangeTopic, collectionTopicPartition, optionalMs, realClock, } from "@procedures/client";
73
+ import { DEFAULT_STORED_FORM, TOMBSTONE_FIELD, storageChangeSource, versionOfStored, } from "./document-storage.js";
74
+ import { trackOwnNumbers } from "./own-writes.js";
75
+ /** The fields of the storage that the change of an item reads. */
76
+ const SEQ = "_seq";
77
+ const KEY = "_key";
78
+ /** The codes of the errors after which the server does not know the resume token: the watcher opens a new stream and publishes a gap. */
79
+ const LOST_TOKEN_CODES = new Set([260, 280, 286]);
80
+ /** The codes of a refused access: Unauthorized, AuthenticationFailed, and the access error of Atlas. */
81
+ const REFUSED_CODES = new Set([13, 18, 8000]);
82
+ /** The codes of the errors of a first open that do not go away: a refused access, no change streams on a single server (40573), a command that the server does not know (115). */
83
+ const PERMANENT_CODES = new Set([...REFUSED_CODES, 40573, 115]);
84
+ /** The most partitions whose topics get the hint of a gap. */
85
+ const MAX_GAP_PARTITIONS = 10_000;
86
+ /** The most raw writes of one adoption: they take one block of numbers for each partition. */
87
+ const ADOPT_BATCH = 100;
88
+ /** The most raw writes that wait for an adoption. The hint of a raw write over the limit goes out without a version. */
89
+ const MAX_ADOPT_QUEUE = 10_000;
90
+ /** The tries of an adoption that fails with an error that can go away. */
91
+ const ADOPT_TRIES = 5;
92
+ /** The documents of one page of the sweep. */
93
+ const SWEEP_PAGE = 500;
94
+ /** The most raw writes that the watcher keeps to adopt again. Over the limit, the partition gets a reset hint. */
95
+ const MAX_ADOPT_REDO = 10_000;
96
+ /** The most documents that the sweep cannot place and that the next sweeps leave. */
97
+ const MAX_UNPLACED = 10_000;
98
+ /** The hint of a gap: no ids, and the subscribers read the collection in full. */
99
+ function resetHint() {
100
+ return { changed: [], deleted: [], reset: true };
101
+ }
102
+ /** The code of a command that the server does not know (an old server without `hello`). */
103
+ const COMMAND_NOT_FOUND = 59;
104
+ function asDocument(value) {
105
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
106
+ }
107
+ function seqOf(doc) {
108
+ const seq = doc?.[SEQ];
109
+ return typeof seq === "number" ? seq : undefined;
110
+ }
111
+ /** The new `_seq` of a write of the storage, read from the event, or undefined for a raw write. */
112
+ function writtenSeq(type, change, after, before) {
113
+ if (type === "update")
114
+ return seqOf(asDocument(asDocument(change["updateDescription"])?.["updatedFields"]));
115
+ const seq = seqOf(after);
116
+ // A replace that copies the `_seq` of the document before it is a raw write. It needs the image before the change.
117
+ if (seq !== undefined && type === "replace" && before !== undefined && seqOf(before) === seq)
118
+ return undefined;
119
+ return seq;
120
+ }
121
+ /** The `_seq` of the document after a raw write: the state that the event saw. */
122
+ function keptSeq(type, change, after, before) {
123
+ if (type !== "update")
124
+ return seqOf(after);
125
+ const removed = asDocument(change["updateDescription"])?.["removedFields"];
126
+ if (Array.isArray(removed) && removed.includes(SEQ))
127
+ return undefined;
128
+ // An update that did not touch `_seq` keeps the `_seq` before it. Without the image, the lookup tells it.
129
+ return seqOf(before ?? after);
130
+ }
131
+ /**
132
+ * This function reads the change of an item from a change event of a `MongoDocumentStorage`
133
+ * collection (roadmap CLI-77), for an app that watches the collection itself. It gives `undefined`
134
+ * for an event of no item: another operation (for example `drop`), an update whose document is
135
+ * gone (its delete event follows), the delete of a tombstone, or a document whose id neither
136
+ * `idOf` nor `_key` tells.
137
+ *
138
+ * - The partition is `form.partitionOf()` of `fullDocumentBeforeChange`, else of `fullDocument`,
139
+ * else of `documentKey`: the first whose partition gives the id with `form.idOf()` of the stored
140
+ * `_id`. Else the id is `_key`, in the partition of the first document.
141
+ * - A tombstone and a delete are deletes.
142
+ * - `raw` tells a raw write, from the event only (see the module comment). A write of the storage
143
+ * has the version of its new `_seq`. A raw write has no version.
144
+ *
145
+ * @param change - A change event of the collection, with `fullDocument: "updateLookup"` for the updates
146
+ * @param form - The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`)
147
+ */
148
+ export function storageChangeOf(change, form = DEFAULT_STORED_FORM) {
149
+ const type = change["operationType"];
150
+ if (type !== "insert" && type !== "replace" && type !== "update" && type !== "delete")
151
+ return undefined;
152
+ const after = type === "delete" ? undefined : asDocument(change["fullDocument"]);
153
+ const before = asDocument(change["fullDocumentBeforeChange"]);
154
+ const key = asDocument(change["documentKey"]);
155
+ // The partition: of the image before the change, else of the document after it, else of the stored _id (the order of the adoption)
156
+ const places = [before, after, key].filter((doc) => doc !== undefined);
157
+ if (places.length === 0)
158
+ return undefined;
159
+ const storedId = (key ?? places[0])["_id"];
160
+ let partition;
161
+ let id;
162
+ for (const doc of places) {
163
+ const place = form.partitionOf(doc);
164
+ const fromId = form.idOf(storedId, place);
165
+ if (typeof fromId === "string") {
166
+ partition = place;
167
+ id = fromId;
168
+ break;
169
+ }
170
+ }
171
+ if (id === undefined) {
172
+ // No partition tells the id through the form: the id is `_key`, in the first partition
173
+ const keyField = (after ?? before)?.[KEY];
174
+ if (typeof keyField !== "string")
175
+ return undefined;
176
+ partition = form.partitionOf(places[0]);
177
+ id = keyField;
178
+ }
179
+ if (!after) {
180
+ // An update whose document is gone: the delete event follows. A delete of a tombstone deletes no item.
181
+ if (type !== "delete" || before?.[TOMBSTONE_FIELD] === true)
182
+ return undefined;
183
+ // The storage deletes an item with a tombstone: a delete of an item is a raw write
184
+ return { id, partition, deleted: true, raw: true };
185
+ }
186
+ const deleted = after[TOMBSTONE_FIELD] === true;
187
+ const written = writtenSeq(type, change, after, before);
188
+ if (written === undefined) {
189
+ const seq = keptSeq(type, change, after, before);
190
+ return { id, partition, deleted, raw: true, ...(seq !== undefined && { seq }) };
191
+ }
192
+ if (deleted)
193
+ return { id, partition, deleted: true, raw: false, seq: written };
194
+ const version = versionOfStored({ ...after, [SEQ]: written }, form, partition) ?? String(written);
195
+ return { id, partition, deleted: false, raw: false, version, seq: written };
196
+ }
197
+ /** The delay before try `attempt` (0 for the first) of a backoff. */
198
+ function backoffOf(options) {
199
+ const initial = optionalMs(options?.initialDelay, "backoff.initialDelay") ?? 250;
200
+ const max = optionalMs(options?.maxDelay, "backoff.maxDelay") ?? 30_000;
201
+ const factor = options?.backoffMultiplier ?? 2;
202
+ const jitter = options?.jitter ?? 1;
203
+ if (!(initial >= 0) || !(max >= initial))
204
+ throw new RangeError("backoff: initialDelay must be 0 or more, and maxDelay must be initialDelay or more");
205
+ if (!(factor >= 1))
206
+ throw new RangeError("backoff: backoffMultiplier must be 1 or more");
207
+ if (!(jitter >= 0 && jitter <= 1))
208
+ throw new RangeError("backoff: jitter must be from 0 to 1");
209
+ return (attempt) => {
210
+ const full = Math.min(max, initial * factor ** attempt);
211
+ return full - full * jitter * Math.random();
212
+ };
213
+ }
214
+ /** The message of an error. */
215
+ function messageOf(error) {
216
+ return String(error?.message ?? error);
217
+ }
218
+ function codeOf(error) {
219
+ const code = error?.code;
220
+ return typeof code === "number" ? code : undefined;
221
+ }
222
+ /** True for a refused access: the user may not write (or read) the collection. */
223
+ function isRefused(error) {
224
+ const code = codeOf(error);
225
+ return code !== undefined && REFUSED_CODES.has(code);
226
+ }
227
+ /** True for an error of a first open that does not go away: a single server, a refused access, a wrong option. */
228
+ function isPermanent(error) {
229
+ const code = codeOf(error);
230
+ if (code !== undefined && PERMANENT_CODES.has(code))
231
+ return true;
232
+ return error instanceof MongoAPIError || error instanceof MongoParseError;
233
+ }
234
+ /** A stored `_id` for the log. */
235
+ function describeId(storedId) {
236
+ try {
237
+ return BSON.EJSON.stringify({ _id: storedId }, { relaxed: false });
238
+ }
239
+ catch {
240
+ return String(storedId);
241
+ }
242
+ }
243
+ /** The key of a stored `_id` in the sets of the watcher, or undefined without an id. */
244
+ function idKey(storedId) {
245
+ return storedId === undefined || storedId === null ? undefined : describeId(storedId);
246
+ }
247
+ /**
248
+ * This function watches the change stream of the collection of a `MongoDocumentStorage`, and
249
+ * publishes a hint of each change of an item on the topic of the collection procedures (roadmap
250
+ * CLI-77). The writes that do not go through the procedures (a raw write, another process, a
251
+ * script) then reach the subscribers too, and the storage adopts each raw write (D39). See the
252
+ * module comment for the rules.
253
+ *
254
+ * - The storage of a partition (`forPartition()`) publishes only the documents of its partition.
255
+ * - The watcher reads only the collection of the items. The documents of the counters
256
+ * (`__storage_counters`) are in another collection, and a change there is never a hint.
257
+ * - An error of the publisher goes to the log. It never stops the watcher.
258
+ * - The adoption of raw writes needs write access. A refused adoption goes to the log once, and
259
+ * the hint of that write goes out without a version: set `rawWrites: "ignore"` for a user that
260
+ * can only read.
261
+ * - The adoption is exact only with `changeStreamPreAndPostImages` on the collection. Without the
262
+ * image, a raw update and a later write of the storage can give that write an extra version.
263
+ *
264
+ * @param storage - The storage, for its collection, its database and its stored form
265
+ * @param publish - The function or the bus of the events, for example the bus of the procedures
266
+ * @param options - The topic, the own writes, the raw writes, the gap, the backoff, the clock and the log
267
+ * @returns The handle: `ready` and `stop()`
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * const notes = new MongoDocumentStorage<Note>({ collection: "notes" });
272
+ * const watch = watchStorageChanges(notes, bus); // the bus of the procedures
273
+ * await watch.ready;
274
+ * // ... a raw write of a note reaches the subscribers of collections.notes.<partition>
275
+ * await watch.stop();
276
+ * ```
277
+ */
278
+ export function watchStorageChanges(storage, publish, options = {}) {
279
+ const source = storageChangeSource(storage);
280
+ const name = options.collection ?? source.collection;
281
+ const topicOf = options.topic ?? ((collection, partition) => collectionChangeTopic(collection, partition));
282
+ const clock = options.clock ?? realClock;
283
+ const log = options.log ?? ((level, message) => console[level === "info" ? "log" : level](`[storage-hints] ${message}`));
284
+ const emit = typeof publish === "function" ? publish : publish.emit.bind(publish);
285
+ // The subscribed topics of the bus name the partitions of a gap. A custom topic rule cannot be read back.
286
+ const channels = typeof publish === "object" && typeof publish.channels === "function" && options.topic === undefined ? publish.channels.bind(publish) : undefined;
287
+ const delayOf = backoffOf(options.backoff);
288
+ if (options.ownWrites !== undefined && options.ownWrites !== "skip" && options.ownWrites !== "publish")
289
+ throw new TypeError('ownWrites must be "skip" or "publish"');
290
+ if (options.rawWrites !== undefined && options.rawWrites !== "adopt" && options.rawWrites !== "ignore")
291
+ throw new TypeError('rawWrites must be "adopt" or "ignore"');
292
+ const skipOwn = (options.ownWrites ?? "skip") === "skip";
293
+ const adoptRaw = (options.rawWrites ?? "adopt") === "adopt";
294
+ if (options.sweep !== undefined && typeof options.sweep !== "boolean")
295
+ throw new TypeError("sweep must be a boolean");
296
+ if (options.sweep === true && !adoptRaw)
297
+ throw new TypeError('sweep needs rawWrites: "adopt"');
298
+ if (options.sweepAtOpen !== undefined && typeof options.sweepAtOpen !== "boolean")
299
+ throw new TypeError("sweepAtOpen must be a boolean");
300
+ if (options.sweepAtOpen === true && !adoptRaw)
301
+ throw new TypeError('sweepAtOpen needs rawWrites: "adopt"');
302
+ const sweepOn = options.sweep ?? adoptRaw;
303
+ const queueLimit = options.adoptQueueLimit ?? MAX_ADOPT_QUEUE;
304
+ if (!Number.isInteger(queueLimit) || queueLimit < 1)
305
+ throw new RangeError("adoptQueueLimit must be a whole number of 1 or more");
306
+ const redoLimit = options.adoptRedoLimit ?? MAX_ADOPT_REDO;
307
+ if (!Number.isInteger(redoLimit) || redoLimit < 0)
308
+ throw new RangeError("adoptRedoLimit must be a whole number of 0 or more");
309
+ const sweepPage = Math.min(SWEEP_PAGE, queueLimit);
310
+ const redoChunk = Math.min(ADOPT_BATCH, queueLimit);
311
+ let stopped = false;
312
+ let stream;
313
+ let token;
314
+ /** True after a stream was open: a new stream without a token can then miss changes. */
315
+ let opened = false;
316
+ let attempt = 0;
317
+ let timer;
318
+ let own;
319
+ /** True after the first refused adoption: the log tells it once. */
320
+ let refused = false;
321
+ /** True after the server said that it has change streams. */
322
+ let checked = false;
323
+ /** The raw writes that wait for an adoption, and true while one adoption runs. */
324
+ const queue = [];
325
+ let adopting = false;
326
+ /** True after the queue was full, until it is empty: the log tells it once, and the sweep waits. */
327
+ let full = false;
328
+ /** The timers of the adoptions that try again. */
329
+ const retries = new Map();
330
+ /** True when a sweep must run when the stream is open and the queue is empty, and true while one runs. */
331
+ let sweepWanted = options.sweepAtOpen ?? false;
332
+ let sweeping = false;
333
+ /** True while a stream is open: it saw its first resume token or change. */
334
+ let live = false;
335
+ /** The raw writes to adopt again when the queue is empty, by the key of the stored id, and true while that runs. */
336
+ const redo = new Map();
337
+ let redoing = false;
338
+ /** True after the set of the redo was full, until it is empty: the log tells it once. */
339
+ let redoFull = false;
340
+ /** The keys of the documents that the sweep cannot place: the next sweeps leave them. */
341
+ const unplaced = new Set();
342
+ /** The partitions that got a hint: the topics of a gap. */
343
+ const partitions = new Set();
344
+ let markReady = () => undefined;
345
+ let failReady = () => undefined;
346
+ const ready = new Promise((resolve, reject) => {
347
+ markReady = resolve;
348
+ failReady = reject;
349
+ });
350
+ // A caller that does not wait for `ready` gets no unhandled rejection: the log tells the error
351
+ ready.catch(() => undefined);
352
+ const send = (partition, event) => {
353
+ let topic;
354
+ try {
355
+ topic = topicOf(name, partition);
356
+ if (topic === undefined)
357
+ return;
358
+ const result = emit(topic, event);
359
+ if (result instanceof Promise)
360
+ result.catch((error) => log("error", `the publish on ${topic} failed: ${messageOf(error)}`));
361
+ }
362
+ catch (error) {
363
+ log("error", `the publish on ${topic ?? name} failed: ${messageOf(error)}`);
364
+ }
365
+ };
366
+ /** The hint of a change of an item. A raw write that the storage does not adopt has no version. */
367
+ const publishItem = (item) => {
368
+ if (stopped)
369
+ return;
370
+ if (partitions.size < MAX_GAP_PARTITIONS)
371
+ partitions.add(item.partition);
372
+ if (item.deleted)
373
+ return send(item.partition, { changed: [], deleted: [item.id] });
374
+ send(item.partition, item.version === undefined ? { changed: [item.id], deleted: [] } : { changed: [item.id], deleted: [], versions: [{ id: item.id, version: item.version }] });
375
+ };
376
+ /** The partitions of the subscribed topics of the bus and of `gapPartitions`. */
377
+ const extraPartitions = (into) => {
378
+ try {
379
+ for (const topic of channels?.() ?? []) {
380
+ const parsed = collectionTopicPartition(topic);
381
+ if (parsed?.collection === name)
382
+ into.add(parsed.partition);
383
+ }
384
+ }
385
+ catch (error) {
386
+ log("warn", `the bus does not tell its channels: ${messageOf(error)}`);
387
+ }
388
+ try {
389
+ for (const partition of options.gapPartitions?.() ?? [])
390
+ into.add(partition);
391
+ }
392
+ catch (error) {
393
+ log("warn", `gapPartitions failed: ${messageOf(error)}`);
394
+ }
395
+ };
396
+ /** The reset hint of a gap: the subscribers read the collection in full. */
397
+ const gap = (reason) => {
398
+ log("warn", `${reason}: the subscribers read the collection in full`);
399
+ // The raw writes of the gap have no event: the sweep adopts the documents without _seq
400
+ if (sweepOn) {
401
+ sweepWanted = true;
402
+ afterQueue();
403
+ }
404
+ if (source.partition !== undefined)
405
+ return send(source.partition, resetHint());
406
+ const topics = new Set([undefined, ...partitions]);
407
+ void source
408
+ .partitions()
409
+ .then((known) => {
410
+ for (const partition of known)
411
+ topics.add(partition);
412
+ }, (error) => log("warn", `the counters of ${source.collection} cannot be read (${messageOf(error)}): the gap goes to the partitions of the hints and of the subscribers only`))
413
+ .then(() => {
414
+ if (stopped)
415
+ return;
416
+ extraPartitions(topics);
417
+ let count = 0;
418
+ for (const partition of topics) {
419
+ if (count++ >= MAX_GAP_PARTITIONS)
420
+ break;
421
+ send(partition, resetHint());
422
+ }
423
+ });
424
+ };
425
+ /** The reset hint of each partition of the raw writes: their subscribers read the collection in full. */
426
+ const resetPartitions = (jobs) => {
427
+ if (stopped)
428
+ return;
429
+ const done = new Set();
430
+ for (const job of jobs) {
431
+ if (done.has(job.item.partition))
432
+ continue;
433
+ done.add(job.item.partition);
434
+ send(job.item.partition, resetHint());
435
+ }
436
+ };
437
+ /**
438
+ * It keeps a raw write to adopt again when the queue is empty, with the newest write of each
439
+ * document. False when the watcher cannot keep it: no id, or the set is full.
440
+ */
441
+ const remember = (job) => {
442
+ const key = idKey(job.raw.storedId);
443
+ if (key === undefined)
444
+ return false;
445
+ if (redo.has(key) || redo.size < redoLimit) {
446
+ redo.set(key, { item: job.item, raw: job.raw, tries: 0 });
447
+ return true;
448
+ }
449
+ if (!redoFull)
450
+ log("warn", `more than ${redoLimit} raw writes of ${source.collection} wait to be adopted again: the partitions of the next ones get a reset hint, and a sweep runs`);
451
+ redoFull = true;
452
+ return false;
453
+ };
454
+ /** The count and the first of the documents that the current sweep cannot place. */
455
+ let leftCount = 0;
456
+ let leftFirst = "";
457
+ /** A document that the sweep cannot place: the next sweeps leave it. */
458
+ const leave = (storedId, why) => {
459
+ const key = idKey(storedId);
460
+ if (key !== undefined && unplaced.size < MAX_UNPLACED)
461
+ unplaced.add(key);
462
+ if (leftCount++ === 0)
463
+ leftFirst = `${describeId(storedId)} (${why})`;
464
+ };
465
+ /** The end of one raw write of an adoption. The change event of an adoption is its hint. */
466
+ const settle = (job, result, failed) => {
467
+ if (typeof result === "string") {
468
+ // `adopted`: the event of the adoption is the hint. `lost`: a newer write won, and its event is the hint.
469
+ if (result === "unknown") {
470
+ if (job.kind === "sweep")
471
+ leave(job.raw.storedId, "neither its id nor its partition");
472
+ else {
473
+ log("warn", `the raw write of ${describeId(job.raw.storedId)} in ${source.collection} tells neither its id nor its partition: the storage leaves it`);
474
+ publishItem(job.item);
475
+ }
476
+ }
477
+ return job.done?.();
478
+ }
479
+ const error = result.error;
480
+ if (isRefused(error)) {
481
+ if (!refused)
482
+ log("error", `the adoption of a raw write of ${source.collection} was refused (${messageOf(error)}): the watcher needs write access, or set rawWrites: "ignore". The hints of raw writes go out without a version`);
483
+ refused = true;
484
+ publishItem(job.item);
485
+ return job.done?.();
486
+ }
487
+ if (isPermanent(error) || job.tries + 1 >= ADOPT_TRIES) {
488
+ failed.push({ job, error });
489
+ return job.done?.();
490
+ }
491
+ // An error that can go away: the adoption tries again after the backoff
492
+ const retry = clock.after(delayOf(job.tries), () => {
493
+ retries.delete(retry);
494
+ enqueue({ ...job, tries: job.tries + 1 });
495
+ });
496
+ retry.unref?.();
497
+ retries.set(retry, job);
498
+ };
499
+ /** One adoption at a time: the raw writes that wait together take one block of numbers for each storage. */
500
+ const work = async () => {
501
+ adopting = true;
502
+ try {
503
+ while (!stopped && queue.length > 0) {
504
+ const batch = queue.splice(0, ADOPT_BATCH);
505
+ let results;
506
+ try {
507
+ results = await source.adoptAll(batch.map((job) => job.raw));
508
+ }
509
+ catch (error) {
510
+ results = batch.map(() => ({ error }));
511
+ }
512
+ if (stopped) {
513
+ for (const job of batch)
514
+ job.done?.();
515
+ break;
516
+ }
517
+ const failed = [];
518
+ batch.forEach((job, index) => settle(job, results[index] ?? "unknown", failed));
519
+ const first = failed[0];
520
+ if (first) {
521
+ // The last try failed: a reset hint for each partition. The redo tries once more; a second failure is for good.
522
+ const names = [...new Set(failed.map(({ job }) => job.item.partition))].map((partition) => (partition === undefined ? "(none)" : JSON.stringify(partition)));
523
+ log("warn", `the adoption of ${failed.length} raw writes of ${source.collection} failed (${messageOf(first.error)}): the partitions ${names.join(", ")} get a reset hint`);
524
+ for (const { job } of failed)
525
+ if (job.kind !== "redo" && !remember(job))
526
+ sweepWanted = true;
527
+ resetPartitions(failed.map(({ job }) => job));
528
+ }
529
+ }
530
+ if (queue.length === 0)
531
+ full = false;
532
+ }
533
+ finally {
534
+ adopting = false;
535
+ }
536
+ if (stopped)
537
+ for (const job of queue.splice(0))
538
+ job.done?.();
539
+ // The queue is empty: the sweep, then the redo, can start
540
+ else
541
+ afterQueue();
542
+ };
543
+ const enqueue = (job) => {
544
+ if (stopped)
545
+ return job.done?.();
546
+ if (queue.length >= queueLimit) {
547
+ if (!full)
548
+ log("warn", `more than ${queueLimit} raw writes of ${source.collection} wait for an adoption: the next ones are adopted again when the queue is empty`);
549
+ full = true;
550
+ if (remember(job)) {
551
+ // The hint of a raw write of a change event goes out now, without a version. Its adoption gives the version.
552
+ if (job.kind === undefined)
553
+ publishItem(job.item);
554
+ }
555
+ else {
556
+ // The sweep finds the documents without _seq. A write of the sweep had no hint.
557
+ sweepWanted = true;
558
+ if (job.kind !== "sweep")
559
+ resetPartitions([job]);
560
+ }
561
+ return job.done?.();
562
+ }
563
+ queue.push(job);
564
+ if (!adopting)
565
+ void work();
566
+ };
567
+ /** The adoption of a raw write (D39), through the queue. */
568
+ const adopt = (item, change, before) => {
569
+ const after = change["operationType"] === "delete" ? undefined : asDocument(change["fullDocument"]);
570
+ // A hard delete without the image before the change: the storage cannot write its tombstone
571
+ if (!after && !before)
572
+ return publishItem(item);
573
+ const storedId = asDocument(change["documentKey"])?.["_id"];
574
+ enqueue({ item, raw: { storedId, seq: item.seq, after, before }, tries: 0 });
575
+ };
576
+ /** The sweep: each document without `_seq` goes to the queue of the adoptions, one page at a time. */
577
+ const sweep = async () => {
578
+ let last;
579
+ let count = 0;
580
+ leftCount = 0;
581
+ try {
582
+ for (;;) {
583
+ if (stopped)
584
+ return;
585
+ const page = await source.rawDocuments(last, sweepPage);
586
+ const end = page[page.length - 1];
587
+ if (!end)
588
+ break;
589
+ last = { id: end["_id"] };
590
+ const waits = [];
591
+ for (const doc of page) {
592
+ const key = idKey(doc["_id"]);
593
+ if (key !== undefined && unplaced.has(key))
594
+ continue;
595
+ let item;
596
+ try {
597
+ item = storageChangeOf({ operationType: "insert", fullDocument: doc, documentKey: { _id: doc["_id"] } }, source.form);
598
+ }
599
+ catch (error) {
600
+ leave(doc["_id"], messageOf(error));
601
+ continue;
602
+ }
603
+ if (!item) {
604
+ leave(doc["_id"], "no id");
605
+ continue;
606
+ }
607
+ if (source.partition !== undefined && item.partition !== source.partition) {
608
+ leave(doc["_id"], "another partition");
609
+ continue;
610
+ }
611
+ if (!item.raw)
612
+ continue;
613
+ count++;
614
+ const job = { item, raw: { storedId: doc["_id"], seq: undefined, after: doc }, tries: 0, kind: "sweep" };
615
+ waits.push(new Promise((resolve) => enqueue({ ...job, done: resolve })));
616
+ }
617
+ await Promise.all(waits);
618
+ if (page.length < sweepPage)
619
+ break;
620
+ }
621
+ if (stopped)
622
+ return;
623
+ if (count > 0)
624
+ log("info", `the sweep of ${source.collection} sent ${count} documents without _seq to the adoption`);
625
+ if (leftCount > 0)
626
+ log("warn", `the sweep of ${source.collection} cannot place ${leftCount} documents without _seq, for example ${leftFirst}: the next sweeps leave them`);
627
+ }
628
+ catch (error) {
629
+ if (!stopped)
630
+ log("warn", `the sweep of the documents without _seq of ${source.collection} failed: ${messageOf(error)}`);
631
+ }
632
+ };
633
+ /** The redo: each raw write that did not fit or that failed goes to the queue again, with its document now. */
634
+ const adoptAgain = async () => {
635
+ const jobs = [...redo.values()];
636
+ redo.clear();
637
+ redoFull = false;
638
+ for (let start = 0; start < jobs.length; start += redoChunk) {
639
+ if (stopped)
640
+ return;
641
+ const part = jobs.slice(start, start + redoChunk);
642
+ let docs;
643
+ try {
644
+ docs = await source.storedDocuments(part.map((job) => job.raw.storedId));
645
+ }
646
+ catch (error) {
647
+ if (stopped)
648
+ return;
649
+ log("warn", `the documents of ${part.length} raw writes of ${source.collection} to adopt again cannot be read (${messageOf(error)}): their partitions get a reset hint`);
650
+ resetPartitions(part);
651
+ continue;
652
+ }
653
+ const current = new Map(docs.map((doc) => [idKey(doc["_id"]), doc]));
654
+ const waits = [];
655
+ for (const job of part) {
656
+ const doc = current.get(idKey(job.raw.storedId));
657
+ // A newer write (a write of the storage, or a delete) has its own event: no redo
658
+ let raw;
659
+ if (!doc)
660
+ raw = job.raw.after === undefined ? job.raw : undefined;
661
+ else
662
+ raw = seqOf(doc) === job.raw.seq ? { ...job.raw, after: doc } : undefined;
663
+ if (!raw)
664
+ continue;
665
+ const again = { item: job.item, raw, tries: 0, kind: "redo" };
666
+ waits.push(new Promise((resolve) => enqueue({ ...again, done: resolve })));
667
+ }
668
+ await Promise.all(waits);
669
+ }
670
+ };
671
+ /** The work after an empty queue: the sweep when a stream is open, then the redo. */
672
+ const afterQueue = () => {
673
+ if (stopped || full || adopting || queue.length > 0 || sweeping || redoing)
674
+ return;
675
+ if (sweepWanted && live) {
676
+ sweepWanted = false;
677
+ sweeping = true;
678
+ void sweep().finally(() => {
679
+ sweeping = false;
680
+ afterQueue();
681
+ });
682
+ return;
683
+ }
684
+ if (redo.size === 0)
685
+ return;
686
+ redoing = true;
687
+ void adoptAgain()
688
+ .catch((error) => log("warn", `the redo of the raw writes of ${source.collection} failed: ${messageOf(error)}`))
689
+ .finally(() => {
690
+ redoing = false;
691
+ afterQueue();
692
+ });
693
+ };
694
+ const onChange = (change) => {
695
+ token = change["_id"];
696
+ attempt = 0;
697
+ const type = change["operationType"];
698
+ if (type === "drop" || type === "rename" || type === "dropDatabase")
699
+ return gap(`the collection ${source.collection} had a ${String(type)}`);
700
+ const ns = asDocument(change["ns"]);
701
+ if (ns?.["coll"] === source.counters)
702
+ return;
703
+ let item;
704
+ try {
705
+ item = storageChangeOf(change, source.form);
706
+ }
707
+ catch (error) {
708
+ log("warn", `a change of ${source.collection} has no item: ${messageOf(error)}`);
709
+ return;
710
+ }
711
+ if (!item)
712
+ return;
713
+ if (source.partition !== undefined && item.partition !== source.partition)
714
+ return;
715
+ if (!item.raw) {
716
+ // The event of a write of this process: the collection procedures publish it
717
+ if (skipOwn && own && item.seq !== undefined && own.take(item.seq))
718
+ return;
719
+ return publishItem(item);
720
+ }
721
+ const before = asDocument(change["fullDocumentBeforeChange"]);
722
+ // The delete of a tombstone that this process pruned: no item. With the image, storageChangeOf() tells it.
723
+ if (type === "delete" && !before && own?.takePruned(asDocument(change["documentKey"])?.["_id"]))
724
+ return;
725
+ if (!adoptRaw)
726
+ return publishItem(item);
727
+ adopt(item, change, before);
728
+ };
729
+ const schedule = (reason) => {
730
+ if (stopped)
731
+ return;
732
+ const delay = delayOf(attempt++);
733
+ log("warn", `${reason}: the stream of ${source.collection} opens again in ${Math.round(delay)} ms`);
734
+ timer = clock.after(delay, () => {
735
+ timer = undefined;
736
+ void open();
737
+ });
738
+ timer.unref?.();
739
+ };
740
+ /** The end of the adoptions that wait: a stopped watcher adopts nothing. */
741
+ const endAdoptions = () => {
742
+ for (const [retry, job] of retries) {
743
+ retry();
744
+ job.done?.();
745
+ }
746
+ retries.clear();
747
+ for (const job of queue.splice(0))
748
+ job.done?.();
749
+ redo.clear();
750
+ };
751
+ /** The end of a watcher whose first open failed with an error that does not go away. */
752
+ const fail = (error) => {
753
+ log("error", `the stream of ${source.collection} cannot open (${messageOf(error)}): the watcher stops`);
754
+ stopped = true;
755
+ endAdoptions();
756
+ own?.close();
757
+ own = undefined;
758
+ failReady(error);
759
+ };
760
+ const open = async () => {
761
+ if (stopped)
762
+ return;
763
+ let db;
764
+ try {
765
+ db = await source.db();
766
+ }
767
+ catch (error) {
768
+ if (!opened && isPermanent(error))
769
+ return fail(error);
770
+ return schedule(`the database is not available (${messageOf(error)})`);
771
+ }
772
+ if (stopped)
773
+ return;
774
+ if (!checked) {
775
+ // On a single server, the driver can wait without an error: the watcher asks the server first
776
+ let hello;
777
+ try {
778
+ hello = await db.command({ hello: 1 });
779
+ }
780
+ catch (error) {
781
+ // An old server without hello: the stream tells the error
782
+ if (codeOf(error) !== COMMAND_NOT_FOUND) {
783
+ if (!opened && isPermanent(error))
784
+ return fail(error);
785
+ return schedule(`the server does not answer hello (${messageOf(error)})`);
786
+ }
787
+ }
788
+ if (stopped)
789
+ return;
790
+ if (hello && typeof hello["setName"] !== "string" && hello["msg"] !== "isdbgrid") {
791
+ return fail(Object.assign(new Error("change streams need a replica set or a sharded cluster, and the server is a single server"), { code: 40573 }));
792
+ }
793
+ checked = true;
794
+ }
795
+ own ??= trackOwnNumbers(db, source.collection, { numbers: skipOwn, clock });
796
+ if (token === undefined && opened)
797
+ gap(`the stream of ${source.collection} opens again without a resume token`);
798
+ let next;
799
+ try {
800
+ next = db.collection(source.collection).watch([], {
801
+ fullDocument: "updateLookup",
802
+ fullDocumentBeforeChange: "whenAvailable",
803
+ ...(token !== undefined && { startAfter: token }),
804
+ });
805
+ }
806
+ catch (error) {
807
+ if (!opened && isPermanent(error))
808
+ return fail(error);
809
+ return schedule(`the stream cannot open (${messageOf(error)})`);
810
+ }
811
+ stream = next;
812
+ let ended = false;
813
+ const end = (reason, error) => {
814
+ if (ended)
815
+ return;
816
+ ended = true;
817
+ next.close().catch(() => undefined);
818
+ if (stream !== next || stopped)
819
+ return;
820
+ stream = undefined;
821
+ live = false;
822
+ if (!opened && error !== undefined && isPermanent(error))
823
+ return fail(error);
824
+ const code = codeOf(error);
825
+ if (code !== undefined && LOST_TOKEN_CODES.has(code) && token !== undefined) {
826
+ // The next stream starts now, without the token: it publishes a gap
827
+ log("warn", `the server does not know the resume token of ${source.collection} (code ${code})`);
828
+ token = undefined;
829
+ }
830
+ schedule(`the stream ${reason}`);
831
+ };
832
+ next.on("resumeTokenChanged", (resumeToken) => {
833
+ if (resumeToken === undefined || resumeToken === null)
834
+ return;
835
+ token = resumeToken;
836
+ opened = true;
837
+ attempt = 0;
838
+ markReady();
839
+ live = true;
840
+ afterQueue();
841
+ });
842
+ next.on("change", (change) => {
843
+ opened = true;
844
+ markReady();
845
+ live = true;
846
+ afterQueue();
847
+ if (change["operationType"] === "invalidate") {
848
+ // The collection was dropped or renamed: the stream starts after the invalidate event
849
+ token = change["_id"];
850
+ return end("ended (invalidate)");
851
+ }
852
+ onChange(change);
853
+ });
854
+ next.on("error", (error) => end(`failed: ${error.message}`, error));
855
+ next.on("close", () => end("closed"));
856
+ };
857
+ void open();
858
+ return {
859
+ ready,
860
+ async stop() {
861
+ if (stopped)
862
+ return;
863
+ stopped = true;
864
+ timer?.();
865
+ timer = undefined;
866
+ endAdoptions();
867
+ own?.close();
868
+ own = undefined;
869
+ markReady();
870
+ const current = stream;
871
+ stream = undefined;
872
+ await current?.close().catch(() => undefined);
873
+ },
874
+ };
875
+ }
876
+ //# sourceMappingURL=storage-hints.js.map