@procedures/mongo 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,8 @@
33
33
  * 1000 rows. It keeps nothing between passes.
34
34
  * - A change stream that ends (for example after `dropDatabase`) opens again after `rewatch`,
35
35
  * and a pass catches up on the changes that the stream missed.
36
+ * - Each stream is open on the server before the materializer reads what it watches: the views
37
+ * before the read of the views, the sources before a run. A change between the two reaches both.
36
38
  * - The status of each view (`ok`, `errored`, `cycle`) goes into the field `statusField` of its
37
39
  * document. The materializer ignores an update of only that field.
38
40
  *
@@ -44,17 +46,20 @@
44
46
  * that moves a document to another partition also reaches the views of its old partition.
45
47
  *
46
48
  * Change streams and transactions need a replica set (MongoDB 6 or later). The target of a view is
47
- * a plain collection that only the materializer writes.
49
+ * a plain collection that only the materializer writes. A target of `storageTargets` is in the
50
+ * stored form of a `MongoDocumentStorage` instead (roadmap CLI-81): see `ViewStorageTarget`.
48
51
  *
49
52
  * The stop has the parts of a transport of a service: `stopIntake()`, `drain()`, `inFlight()` and
50
53
  * `close()`. A host composes them with no adapter. The timers come from the injected clock.
51
54
  */
52
55
 
53
56
  import { createHash } from "node:crypto";
54
- import { BSON, ObjectId, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
55
- import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client";
56
- import { DEFAULT_STORED_FORM, TOMBSTONE_FIELD, type StoredForm } from "./document-storage.js";
57
+ import { isDeepStrictEqual } from "node:util";
58
+ import { BSON, ObjectId, type AnyBulkWriteOperation, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
59
+ import { collectionChangeTopic, ms, optionalMs, realClock, type Cancel, type Clock, type CollectionChangeEvent, type Duration } from "@procedures/client";
60
+ import { DEFAULT_STORED_FORM, DOCUMENT_STORAGE_FIELDS, MongoDocumentStorage, TOMBSTONE_FIELD, storageWriteTarget, type StorageWriteTarget, type StoredForm } from "./document-storage.js";
57
61
  import { scopePipeline } from "./pipeline-scope.js";
62
+ import type { StorageHintPublisher } from "./storage-hints.js";
58
63
 
59
64
  /** The fields of a storage that a row never keeps: its sequence number and its tombstone flag (roadmap CLI-77). */
60
65
  const ROW_DROPPED_FIELDS = ["_seq", TOMBSTONE_FIELD] as const;
@@ -155,6 +160,82 @@ export interface ViewMaterializerOptions {
155
160
  * When the form or the function gives `undefined`, the change reaches the views of each partition.
156
161
  */
157
162
  storageSources?: Readonly<Record<string, ViewPartitionOf | ViewStorageSource>> | undefined;
163
+ /**
164
+ * The targets in the stored form of a `MongoDocumentStorage` (roadmap CLI-81), by the name of the
165
+ * target collection: the storage of the target, its stored form, or a `ViewStorageTarget`. The
166
+ * rows of such a target are documents of the storage, so a reader reads them through a
167
+ * `MongoDocumentStorage` with the same form, and `changes(since)` and `watchStorageChanges()`
168
+ * have each write of a run. See `ViewStorageTarget` for the rules. Give the same targets to each
169
+ * materializer of the views.
170
+ *
171
+ * A function gives the entry of a target collection at each run, or undefined for a target that
172
+ * is not in a stored form: an app can name its targets at run time. The materializer keeps the
173
+ * storage that it makes for a collection while the entry gives the same form, counters,
174
+ * `writeLease` and `maxTombstones`. An error of the function is an error of the view.
175
+ */
176
+ storageTargets?: Readonly<Record<string, ViewStorageTargetEntry>> | ((collection: string) => ViewStorageTargetEntry | undefined) | undefined;
177
+ /**
178
+ * The id of a row of a target in a stored form, from the `_id` of the row in the result. It must
179
+ * give a text, and give the same text for the same `_id` at each run.
180
+ * @default `defaultStorageRowId`: a text as it is, another value as its relaxed Extended JSON text
181
+ */
182
+ storageRowIdOf?: ((id: unknown) => string) | undefined;
183
+ /**
184
+ * The publisher of the hints of the targets that are not in `storageTargets`, for example the bus
185
+ * of the procedures. After each run that changes rows, the materializer publishes one hint with
186
+ * the ids of the changed rows and of the removed rows. A change of generation, a row id that is
187
+ * not a text and more than 10000 ids give a reset hint (`reset: true`): the subscribers read the
188
+ * view in full. The materializer publishes no hint of a target in a stored form: a watcher of
189
+ * the storage (`watchStorageChanges()`) does.
190
+ */
191
+ publish?: StorageHintPublisher | undefined;
192
+ /** The topic of the hints of a view, or undefined for no hint (default `collectionChangeTopic(view._id)`). */
193
+ hintTopic?: ((view: MaterializedView, partition: string) => string | undefined) | undefined;
194
+ /**
195
+ * The `_id` of a row in the result, from its stored `_id`, for the ids of a hint. It reverses
196
+ * `targetId`. The default reads the field `id` of the form of `partitionTargetId`.
197
+ */
198
+ rowIdOf?: ((storedId: unknown) => unknown) | undefined;
199
+ }
200
+
201
+ /**
202
+ * A storage with items of any type. The type of the items does not change the stored form, and a
203
+ * `MongoDocumentStorage<T>` of a given `T` is not a `MongoDocumentStorage<unknown>`.
204
+ */
205
+ type AnyDocumentStorage = MongoDocumentStorage<any>;
206
+
207
+ /** An entry of `storageTargets`: the storage of a target, its stored form, or a `ViewStorageTarget`. */
208
+ export type ViewStorageTargetEntry = AnyDocumentStorage | StoredForm | ViewStorageTarget;
209
+
210
+ /**
211
+ * A target of `storageTargets` (roadmap CLI-81).
212
+ *
213
+ * - The rows are documents of the storage: each row gets `_key`, `_seq`, `_ord`, the partition
214
+ * fields of the form, and `_kid`, `_kpf` or `_json` as a write of the storage makes them. The
215
+ * materializer encodes each row from its own content: the fields of a storage in a row (for
216
+ * example of a source document that the pipeline passes through) do not go into the target.
217
+ * - The id of a row is `storageRowIdOf(_id)`: by default the `_id` of the row when it is a text,
218
+ * else its relaxed Extended JSON text (`null` gives `"null"`, `5` gives `"5"`, an ObjectId gives
219
+ * `{"$oid":"…"}`). The item has that id as its `_id`. Two rows of a result must not have the
220
+ * same id.
221
+ * - A row also gets the partition field of the materializer with the partition of the view, so a
222
+ * view that reads the target finds it. When that field is a partition field of the form, the
223
+ * form must give it the partition of the view.
224
+ * - A run takes the numbers of its writes from the sequence of the storage, and a late run moves
225
+ * to new numbers, as a write of the storage does. A removed row becomes a tombstone, and the
226
+ * prune of the tombstones raises the floor of the change log.
227
+ * - A partition of the storage holds only the rows of one view. The run makes a tombstone of each
228
+ * other live document in that partition. The materializer refuses a second view of the same
229
+ * target collection and partition of the storage: that view has an error.
230
+ * - The materializer gives the target no images before the change. Turn on
231
+ * `changeStreamPreAndPostImages` on the target when raw hard deletes can occur.
232
+ * - The rows have no generation, so `viewFilter()` and a `MongoViewStorage` do not read them.
233
+ */
234
+ export interface ViewStorageTarget {
235
+ /** The storage of the target, or its stored form. The materializer writes with its own database, and takes the counters, `writeLease` and `maxTombstones` of a storage. */
236
+ target: AnyDocumentStorage | StoredForm;
237
+ /** The partition of the storage from the partition of a view (default the same partition). Undefined is the storage without a partition. */
238
+ storagePartitionOf?: ((viewPartition: string) => string | undefined) | undefined;
158
239
  }
159
240
 
160
241
  /** The function that gives the stored `_id` of a row of a view. */
@@ -212,6 +293,72 @@ function groups<T>(items: readonly T[]): T[][] {
212
293
  return result;
213
294
  }
214
295
 
296
+ /** The most tries of a run in a transaction after a write conflict (code 112). After them, the run gives way, and comes back after `rewatch`. */
297
+ const CONFLICT_RETRIES = 3;
298
+
299
+ /** The most ids of a hint of a target not in a stored form. More ids give a reset hint. */
300
+ const MAX_HINT_IDS = 10_000;
301
+
302
+ /** The `_id` of a row in the result, from the stored `_id` of `partitionTargetId`. */
303
+ export function defaultRowId(storedId: unknown): unknown {
304
+ return typeof storedId === "object" && storedId !== null && "id" in storedId ? (storedId as { id: unknown }).id : storedId;
305
+ }
306
+
307
+ /**
308
+ * The id of a row of a target in a stored form, from the `_id` of the row in the result: a text as
309
+ * it is, another value as its relaxed Extended JSON text. The text of a value is the same at each
310
+ * run (the order of the fields of an object is the order of the result). A row without `_id` has
311
+ * no id.
312
+ */
313
+ export function defaultStorageRowId(id: unknown): string {
314
+ if (typeof id === "string") return id;
315
+ if (id === undefined) throw new TypeError("the row has no _id");
316
+ return BSON.EJSON.stringify(id as Document, { relaxed: true });
317
+ }
318
+
319
+ /** The hash of a stored document without its numbers (`_seq` and `_ord`): two documents with the same content give the same hash. */
320
+ function contentHash(doc: Document): string {
321
+ const { _seq: _number, _ord: _place, ...rest } = doc;
322
+ return createHash("sha1")
323
+ .update(canonical(BSON.EJSON.serialize(rest, { relaxed: false })))
324
+ .digest("hex");
325
+ }
326
+
327
+ /** The values of a target in a stored form that make its storage. */
328
+ interface StorageTargetValues {
329
+ readonly form: StoredForm;
330
+ readonly counters: string | undefined;
331
+ readonly writeLeaseMs: number | undefined;
332
+ readonly maxTombstones: number | undefined;
333
+ }
334
+
335
+ /** A target of `storageTargets`, ready to write: a storage on the database of the materializer. */
336
+ interface StorageTargetEntry {
337
+ readonly storage: MongoDocumentStorage<unknown>;
338
+ readonly storagePartitionOf: (viewPartition: string) => string | undefined;
339
+ }
340
+
341
+ /** The storage of the partition of a view in its target (roadmap CLI-81). */
342
+ interface TargetPartition {
343
+ readonly writer: StorageWriteTarget;
344
+ /** The filter of the rows of the view: the partition of the storage and the partition of the view. */
345
+ readonly filter: Document;
346
+ /** True when the partition field of the materializer goes in the content of an item (not a field of the storage or of the form). */
347
+ readonly partitionInContent: boolean;
348
+ }
349
+
350
+ /** A row of a target in a stored form that a run writes, with the place of its stored document (`_ord`) when it has one. */
351
+ interface StorageWrite {
352
+ readonly row: StoredRow;
353
+ readonly ord: number | undefined;
354
+ }
355
+
356
+ /** A row of a target in a stored form that a run removes: its stored `_id`, and its id when the stored document tells one that the result does not have. */
357
+ interface StorageRemoval {
358
+ readonly storedId: unknown;
359
+ readonly id: string | undefined;
360
+ }
361
+
215
362
  /** The function that gives the partition of the views from the partition of a storage, or `undefined` when it does not know. */
216
363
  export type ViewPartitionOf = (storagePartition: string) => string | undefined;
217
364
 
@@ -226,6 +373,23 @@ export interface ViewStorageSource {
226
373
  viewPartitionOf: ViewPartitionOf;
227
374
  }
228
375
 
376
+ /**
377
+ * This function waits until a change stream is open on the server: its first resume token. From
378
+ * then on, the stream gets each change. The driver sends the `aggregate` of a stream only after
379
+ * `watch()` returns, so a change before that point does not reach the stream. An error, an end or a
380
+ * close of the stream also ends the wait: its rewatch catches up (roadmap O63).
381
+ */
382
+ function openOnServer(stream: ChangeStream): Promise<void> {
383
+ return new Promise((resolve) => {
384
+ const events = ["resumeTokenChanged", "error", "end", "close"] as const;
385
+ const done = (): void => {
386
+ for (const event of events) stream.off(event, done);
387
+ resolve();
388
+ };
389
+ for (const event of events) stream.on(event, done);
390
+ });
391
+ }
392
+
229
393
  /** A view that the materializer knows. */
230
394
  interface RunningView {
231
395
  view: MaterializedView;
@@ -237,10 +401,10 @@ interface RunningView {
237
401
 
238
402
  /** A path without its positions in arrays (the segments of digits only): `lines.0.price` gives `lines.price`. */
239
403
  function withoutPositions(path: string): string {
240
- return path
241
- .split(".")
242
- .filter((segment) => !/^\d+$/.test(segment))
243
- .join(".");
404
+ // path-check: a field path of a MongoDB document, not a procedure path.
405
+ const segments = path.split(".");
406
+ // path-check: a field path of a MongoDB document, not a procedure path.
407
+ return segments.filter((segment) => !/^\d+$/.test(segment)).join(".");
244
408
  }
245
409
 
246
410
  /** The key of a row id for the signatures: type-aware, so an ObjectId and its text are different keys. */
@@ -293,11 +457,19 @@ export class ViewMaterializer {
293
457
  private readonly graceMs: number;
294
458
  private readonly leaseMs: number;
295
459
  private readonly storageSources: ReadonlyMap<string, ViewStorageSource>;
460
+ /** The storages that the materializer made for the targets in a stored form, by collection, with the values that made them. */
461
+ private readonly storageTargetCache = new Map<string, StorageTargetValues & { storage: MongoDocumentStorage<unknown> }>();
462
+ /** The partition of a storage target that each running view writes (by the key of the view), so a second view of it is refused. */
463
+ private readonly storageClaims = new Map<string, { claim: string; view: string; partition: string }>();
464
+ private readonly storageRowIdOf: (id: unknown) => string;
465
+ private readonly rowIdOf: (storedId: unknown) => unknown;
296
466
  /** False after `stopIntake()`: a change starts no pass. */
297
467
  private intake = false;
298
468
  /** The work of each partition, one at a time: the passes and the rebalances. */
299
469
  private readonly chains = new Map<string, Promise<void>>();
300
470
  private readonly pending = new Set<Promise<unknown>>();
471
+ /** The changes of the set of views (a resync, a change of a view document), one at a time, in the order of the views stream. */
472
+ private viewChain: Promise<unknown> = Promise.resolve();
301
473
  private stopped = true;
302
474
 
303
475
  constructor(options: ViewMaterializerOptions) {
@@ -323,6 +495,63 @@ export class ViewMaterializer {
323
495
  return [source, normal];
324
496
  })
325
497
  );
498
+ // A record of targets is examined at once: a wrong entry is an error of the options
499
+ const targets = options.storageTargets;
500
+ if (targets !== undefined && typeof targets !== "function") {
501
+ for (const [collection, entry] of Object.entries(targets)) this.storageTargetOf(collection, entry);
502
+ }
503
+ this.storageRowIdOf = options.storageRowIdOf ?? defaultStorageRowId;
504
+ this.rowIdOf = options.rowIdOf ?? defaultRowId;
505
+ }
506
+
507
+ /**
508
+ * A target of `storageTargets` as a storage on the database of the materializer, so its writes
509
+ * and the transaction of a run use one client. A given storage gives its form, its counters,
510
+ * `writeLease` and `maxTombstones`.
511
+ */
512
+ private storageTargetOf(collection: string, entry: ViewStorageTargetEntry): StorageTargetEntry {
513
+ const wrapped = typeof entry === "object" && entry !== null && "target" in entry ? entry : { target: entry };
514
+ const storagePartitionOf = wrapped.storagePartitionOf ?? ((partition: string): string | undefined => partition);
515
+ if (typeof storagePartitionOf !== "function") throw new TypeError(`The storage target ${collection} needs a function storagePartitionOf`);
516
+ const target = wrapped.target;
517
+ let values: StorageTargetValues;
518
+ if (target instanceof MongoDocumentStorage) {
519
+ const parts = storageWriteTarget(target);
520
+ if (parts.collection !== collection) throw new TypeError(`The storage target ${collection} has a storage of the collection ${parts.collection}`);
521
+ values = { form: parts.form, counters: parts.counters, writeLeaseMs: parts.writeLeaseMs, maxTombstones: parts.maxTombstones };
522
+ } else if (typeof target === "object" && target !== null && typeof (target as Partial<StoredForm>).storedId === "function") {
523
+ values = { form: target as StoredForm, counters: undefined, writeLeaseMs: undefined, maxTombstones: undefined };
524
+ } else {
525
+ throw new TypeError(`The storage target ${collection} needs a MongoDocumentStorage, a stored form or a ViewStorageTarget`);
526
+ }
527
+ // The same values give the storage of the last run: its partitions keep their state (the floor document of O66)
528
+ const cached = this.storageTargetCache.get(collection);
529
+ if (cached && cached.form === values.form && cached.counters === values.counters && cached.writeLeaseMs === values.writeLeaseMs && cached.maxTombstones === values.maxTombstones) {
530
+ return { storage: cached.storage, storagePartitionOf };
531
+ }
532
+ const db = (): Db => {
533
+ if (!this.db) throw new Error("The materializer has not started");
534
+ return this.db;
535
+ };
536
+ const storage = new MongoDocumentStorage<unknown>({
537
+ collection,
538
+ db,
539
+ form: values.form,
540
+ transactions: "never",
541
+ ...(values.counters !== undefined ? { counters: values.counters } : {}),
542
+ ...(values.writeLeaseMs !== undefined ? { writeLease: ms(values.writeLeaseMs) } : {}),
543
+ ...(values.maxTombstones !== undefined ? { maxTombstones: values.maxTombstones } : {}),
544
+ });
545
+ this.storageTargetCache.set(collection, { ...values, storage });
546
+ return { storage, storagePartitionOf };
547
+ }
548
+
549
+ /** The target in a stored form of a collection at this time, or undefined. The function of `storageTargets` runs at each call. */
550
+ private storageTargetFor(collection: string): StorageTargetEntry | undefined {
551
+ const targets = this.options.storageTargets;
552
+ if (targets === undefined) return undefined;
553
+ const entry = typeof targets === "function" ? targets(collection) : Object.hasOwn(targets, collection) ? targets[collection] : undefined;
554
+ return entry === undefined || entry === null ? undefined : this.storageTargetOf(collection, entry);
326
555
  }
327
556
 
328
557
  private log(level: "info" | "warn" | "error", message: string): void {
@@ -368,8 +597,9 @@ export class ViewMaterializer {
368
597
  this.stopped = false;
369
598
  this.intake = true;
370
599
  this.db = await this.options.db();
600
+ // The stream of the views opens before the read of the views: a change between the two reaches both (roadmap O63)
601
+ await this.watchViews();
371
602
  const count = await this.resync();
372
- this.watchViews();
373
603
  this.log("info", `started: ${count} view(s)`);
374
604
  }
375
605
 
@@ -452,23 +682,40 @@ export class ViewMaterializer {
452
682
  return typeof partition === "string" && partition !== "" ? partition : undefined;
453
683
  }
454
684
 
455
- /** This method closes each view, and opens each view of the views collection again: a full catch-up. */
685
+ /**
686
+ * This method runs a change of the set of views after the changes before it. A change of a view
687
+ * document that comes during a resync thus applies after the read of the resync, not before it.
688
+ */
689
+ private inViewOrder<R>(work: () => R | Promise<R>): Promise<R> {
690
+ const run = this.viewChain.then(work);
691
+ this.viewChain = run.catch(() => undefined);
692
+ return run;
693
+ }
694
+
695
+ /**
696
+ * This method closes each view, and opens each view of the views collection again: a full
697
+ * catch-up. Open the stream of the views first: then a change after the read reaches the stream.
698
+ */
456
699
  private async resync(): Promise<number> {
457
- for (const key of [...this.running.keys()]) this.tearDown(key);
458
- const docs = (await this.db!.collection<MaterializedView>(this.viewsName).find({}).toArray()) as MaterializedView[];
459
- for (const view of docs) await this.spinUp(view);
460
- return docs.length;
700
+ const { count, registered } = await this.inViewOrder(async () => {
701
+ const docs = (await this.db!.collection<MaterializedView>(this.viewsName).find({}).toArray()) as MaterializedView[];
702
+ for (const key of [...this.running.keys()]) this.tearDown(key);
703
+ return { count: docs.length, registered: docs.filter((view) => this.register(view)) };
704
+ });
705
+ for (const view of registered) await this.activate(view);
706
+ return count;
461
707
  }
462
708
 
463
- private watchViews(): void {
464
- if (this.stopped || !this.db) return;
709
+ /** This method opens the stream of the views. The promise resolves when the stream is open on the server. */
710
+ private watchViews(): Promise<void> {
711
+ if (this.stopped || !this.db) return Promise.resolve();
465
712
  let stream: ChangeStream;
466
713
  try {
467
714
  stream = this.db.collection(this.viewsName).watch([], { fullDocument: "updateLookup" });
468
715
  } catch (error) {
469
716
  this.log("error", `cannot watch ${this.viewsName}: ${String((error as Error)?.message ?? error)}`);
470
- this.later(() => this.watchViews());
471
- return;
717
+ this.later(() => void this.watchViews());
718
+ return Promise.resolve();
472
719
  }
473
720
  const previous = this.viewsStream;
474
721
  if (previous && previous !== stream) previous.close().catch(() => undefined);
@@ -481,8 +728,8 @@ export class ViewMaterializer {
481
728
  this.log("warn", `the stream of ${this.viewsName} ${reason}: it opens again in ${this.rewatchMs} ms`);
482
729
  stream.close().catch(() => undefined);
483
730
  this.later(() => {
484
- this.watchViews();
485
- void this.track(this.resync()).catch((error: unknown) => this.log("error", `the resync failed: ${String((error as Error)?.message ?? error)}`));
731
+ // The changes while the stream was closed are lost: a resync catches up, after the new stream is open
732
+ void this.track(this.watchViews().then(() => this.resync())).catch((error: unknown) => this.log("error", `the resync failed: ${String((error as Error)?.message ?? error)}`));
486
733
  });
487
734
  };
488
735
 
@@ -499,6 +746,7 @@ export class ViewMaterializer {
499
746
  });
500
747
  stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
501
748
  stream.on("close", () => rewatch("closed"));
749
+ return openOnServer(stream);
502
750
  }
503
751
 
504
752
  private async onViewChange(change: Document): Promise<void> {
@@ -506,39 +754,65 @@ export class ViewMaterializer {
506
754
  if (type === "insert" || type === "replace" || type === "update") {
507
755
  const view = change["fullDocument"] as MaterializedView | null;
508
756
  if (!view) return;
509
- const partition = this.partitionOf(view);
510
- if (partition !== undefined) this.tearDown(this.keyOf(partition, view._id));
511
- await this.spinUp(view);
757
+ const registered = await this.inViewOrder(() => {
758
+ const partition = this.partitionOf(view);
759
+ if (partition !== undefined) this.tearDown(this.keyOf(partition, view._id));
760
+ return this.register(view);
761
+ });
762
+ if (registered) await this.activate(view);
512
763
  } else if (type === "delete") {
513
764
  const id = String((change["documentKey"] as Document)["_id"]);
514
- const partitions = new Set<string>();
515
- for (const [key, view] of [...this.running]) {
516
- if (view.view._id === id) {
517
- partitions.add(view.partition);
518
- this.tearDown(key);
765
+ const partitions = await this.inViewOrder(() => {
766
+ const found = new Set<string>();
767
+ for (const [key, view] of [...this.running]) {
768
+ if (view.view._id === id) {
769
+ found.add(view.partition);
770
+ this.tearDown(key);
771
+ }
519
772
  }
520
- }
773
+ return found;
774
+ });
521
775
  // A removed view can break a cycle of the other views
522
776
  for (const partition of partitions) await this.rebalance(partition);
523
777
  }
524
778
  }
525
779
 
526
- private async spinUp(view: MaterializedView): Promise<void> {
780
+ /** This method adds a view to the views that the materializer knows. It gives false for a view that cannot run. */
781
+ private register(view: MaterializedView): boolean {
527
782
  const partition = this.partitionOf(view);
528
783
  if (partition === undefined) {
529
784
  this.log("warn", `the view ${String(view._id)} has no ${this.partitionField}: it does not run`);
530
- return;
785
+ return false;
531
786
  }
532
787
  if (!Array.isArray(view.collections) || view.collections.length === 0 || typeof view.targetCollection !== "string" || !Array.isArray(view.pipeline)) {
533
788
  this.log("warn", `the view ${view._id} needs collections, a pipeline and a targetCollection: it does not run`);
534
- return;
789
+ return false;
535
790
  }
536
791
  this.running.set(this.keyOf(partition, view._id), { view, partition, state: "active", streams: [] });
792
+ return true;
793
+ }
794
+
795
+ /** This method makes the index of the target of a registered view, and rebalances its partition: a new view runs. */
796
+ private async activate(view: MaterializedView): Promise<void> {
797
+ const partition = this.partitionOf(view)!;
537
798
  // The readers, the counts and the sweeps read the rows of a partition in a generation
538
799
  await this.db!
539
800
  .collection(view.targetCollection)
540
801
  .createIndex({ [this.partitionField]: 1, [VIEW_GENERATION_FIELD]: 1 })
541
802
  .catch((error: unknown) => this.log("error", `the index of ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
803
+ // A target in a stored form also has the indexes of the storage: the change reads, the order and the tombstones
804
+ let storageTarget: StorageTargetEntry | undefined;
805
+ try {
806
+ storageTarget = this.storageTargetFor(view.targetCollection);
807
+ } catch (error) {
808
+ // The run of the view gives the error to its status
809
+ this.log("error", `the storage target ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
810
+ }
811
+ if (storageTarget) {
812
+ await storageWriteTarget(storageTarget.storage)
813
+ .ready()
814
+ .catch((error: unknown) => this.log("error", `the indexes of the storage ${view.targetCollection} failed: ${String((error as Error)?.message ?? error)}`));
815
+ }
542
816
  await this.rebalance(partition);
543
817
  }
544
818
 
@@ -547,6 +821,7 @@ export class ViewMaterializer {
547
821
  if (!view) return;
548
822
  for (const stream of view.streams) stream.close().catch(() => undefined);
549
823
  this.running.delete(key);
824
+ this.storageClaims.delete(key);
550
825
  }
551
826
 
552
827
  /**
@@ -630,8 +905,9 @@ export class ViewMaterializer {
630
905
  return this.running.get(this.keyOf(running.partition, running.view._id)) === running;
631
906
  }
632
907
 
633
- private watchSource(running: RunningView, source: string): void {
634
- if (this.stopped || !this.db) return;
908
+ /** This method opens the stream of a source for a view. The promise resolves when the stream is open on the server. */
909
+ private watchSource(running: RunningView, source: string): Promise<void> {
910
+ if (this.stopped || !this.db) return Promise.resolve();
635
911
  let stream: ChangeStream;
636
912
  try {
637
913
  stream = this.db
@@ -640,9 +916,9 @@ export class ViewMaterializer {
640
916
  } catch (error) {
641
917
  this.log("error", `cannot watch ${source} for the view ${running.view._id}: ${String((error as Error)?.message ?? error)}`);
642
918
  this.later(() => {
643
- if (this.isCurrent(running) && running.state === "active") this.watchSource(running, source);
919
+ if (this.isCurrent(running) && running.state === "active") void this.watchSource(running, source);
644
920
  });
645
- return;
921
+ return Promise.resolve();
646
922
  }
647
923
  running.streams.push(stream);
648
924
  let again = false;
@@ -657,9 +933,8 @@ export class ViewMaterializer {
657
933
  this.log("warn", `the stream of ${source} for the view ${running.view._id} ${reason}: it opens again in ${this.rewatchMs} ms`);
658
934
  this.later(() => {
659
935
  if (!this.isCurrent(running) || running.state !== "active") return;
660
- this.watchSource(running, source);
661
- // The changes while the stream was closed are lost: a pass catches up
662
- this.schedule(running.partition, source);
936
+ // The changes while the stream was closed are lost: a pass catches up, after the new stream is open
937
+ void this.watchSource(running, source).then(() => this.schedule(running.partition, source));
663
938
  });
664
939
  };
665
940
  stream.on("change", (change: Document) => {
@@ -670,6 +945,7 @@ export class ViewMaterializer {
670
945
  });
671
946
  stream.on("error", (error: Error) => rewatch(`failed: ${error.message}`));
672
947
  stream.on("close", () => rewatch("closed"));
948
+ return openOnServer(stream);
673
949
  }
674
950
 
675
951
  /** This method runs a function after `rewatch`, unless the materializer stops. The timer does not keep the process alive. */
@@ -744,7 +1020,10 @@ export class ViewMaterializer {
744
1020
  }
745
1021
  } else if (running.state === "cyclic" || running.streams.length === 0) {
746
1022
  running.state = "active";
747
- for (const source of view.collections) this.watchSource(running, source);
1023
+ // The streams open before the run reads the sources: a change between the two reaches both (roadmap O63)
1024
+ await Promise.all(view.collections.map((source) => this.watchSource(running, source)));
1025
+ // A change of the view document during the wait replaced this view: the rebalance of the new one runs it
1026
+ if (!this.isCurrent(running)) continue;
748
1027
  await this.runAndWriteStatus(view, partition);
749
1028
  }
750
1029
  }
@@ -792,25 +1071,96 @@ export class ViewMaterializer {
792
1071
  */
793
1072
  private async run(view: MaterializedView, partition: string): Promise<boolean> {
794
1073
  const target = this.db!.collection(view.targetCollection);
1074
+ const storage = this.targetPartition(view, partition);
795
1075
  const { status, now } = await this.statusOf(view, partition);
796
1076
  if (status?.lease && status.lease.until instanceof Date && now < status.lease.until) throw new PassSuperseded("a pass of another process holds the view");
797
- await this.dropOldGenerations(target, partition, status, now);
1077
+ // The rows of a target in a stored form have no generation: a removal is a tombstone, never a delete of an old generation
1078
+ if (!storage) await this.dropOldGenerations(target, partition, status, now);
798
1079
  const current = status?.generation;
799
1080
  const generations = await this.currentGenerations(partition);
800
- const size = current ? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current }) : 0;
1081
+ // The floor document of the partition of the storage, before the first write (roadmap O66)
1082
+ if (storage) await storage.writer.noteFloor(this.db!);
1083
+ const size = storage
1084
+ ? await target.countDocuments({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } })
1085
+ : current
1086
+ ? await target.countDocuments({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: current })
1087
+ : 0;
801
1088
  if (size <= this.maxTransactionRows) {
802
- try {
803
- return await this.runInTransaction(view, partition, target, current, generations);
804
- } catch (error) {
805
- if (error instanceof ViewRunError || error instanceof PassSuperseded) throw error;
806
- if (isWriteConflict(error)) throw new PassSuperseded(`another process wrote the view at the same time (${String((error as Error)?.message ?? error)})`);
807
- this.log("warn", `${view._id}: the transaction failed (${String((error as Error)?.message ?? error)}): the pass writes a new generation in groups`);
1089
+ for (let attempt = 1; ; attempt++) {
1090
+ try {
1091
+ return storage
1092
+ ? await this.runStorageInTransaction(view, partition, target, storage, current, generations)
1093
+ : await this.runInTransaction(view, partition, target, current, generations);
1094
+ } catch (error) {
1095
+ if (error instanceof ViewRunError || error instanceof PassSuperseded) throw error;
1096
+ if (isWriteConflict(error)) {
1097
+ // A write conflict tries again a few times after a short wait, so a steady stream of writes does not starve the view
1098
+ if (attempt < CONFLICT_RETRIES) {
1099
+ await this.pause(25 * 2 ** attempt * (1 + Math.random()));
1100
+ continue;
1101
+ }
1102
+ throw new PassSuperseded(`another process wrote the view at the same time (${String((error as Error)?.message ?? error)})`);
1103
+ }
1104
+ this.log("warn", `${view._id}: the transaction failed (${String((error as Error)?.message ?? error)}): the pass writes ${storage ? "in groups" : "a new generation in groups"}`);
1105
+ break;
1106
+ }
808
1107
  }
809
1108
  }
1109
+ if (storage) return this.runStorageInGroups(view, partition, target, storage, current, generations);
810
1110
  await this.runInGroups(view, partition, target, current, generations);
811
1111
  return true;
812
1112
  }
813
1113
 
1114
+ /** This method waits on the clock of the materializer. The timer does not keep the process alive. */
1115
+ private pause(delay: number): Promise<void> {
1116
+ return new Promise((resolve) => {
1117
+ const cancel = this.clock.after(Math.round(delay), resolve);
1118
+ cancel.unref?.();
1119
+ });
1120
+ }
1121
+
1122
+ /**
1123
+ * The storage of the partition of a view in its target, or undefined for a target that is not in
1124
+ * `storageTargets`. A partition that the storage refuses, and a partition field of the form with
1125
+ * another value than the partition of the view, are an error of the view.
1126
+ */
1127
+ private targetPartition(view: MaterializedView, partition: string): TargetPartition | undefined {
1128
+ let entry: StorageTargetEntry | undefined;
1129
+ try {
1130
+ entry = this.storageTargetFor(view.targetCollection);
1131
+ } catch (error) {
1132
+ throw new ViewRunError(`the storage target ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
1133
+ }
1134
+ if (!entry) return undefined;
1135
+ let writer: StorageWriteTarget;
1136
+ let storagePartition: string | undefined;
1137
+ try {
1138
+ storagePartition = entry.storagePartitionOf(partition);
1139
+ writer = storageWriteTarget(storagePartition === undefined ? entry.storage : entry.storage.forPartition(storagePartition));
1140
+ } catch (error) {
1141
+ throw new ViewRunError(`the partition of the storage ${view.targetCollection}: ${String((error as Error)?.message ?? error)}`);
1142
+ }
1143
+ // One view writes a partition of a storage target: its run makes a tombstone of each other document of it
1144
+ const claim = JSON.stringify([view.targetCollection, storagePartition ?? null]);
1145
+ const key = this.keyOf(partition, view._id);
1146
+ for (const [other, held] of this.storageClaims) {
1147
+ if (other !== key && held.claim === claim && this.running.has(other)) {
1148
+ const place = storagePartition === undefined ? "the storage" : `the partition ${storagePartition} of the storage`;
1149
+ throw new ViewRunError(`the view ${held.view} (partition ${held.partition}) writes ${place} ${view.targetCollection} already: one view writes a partition of a storage target`);
1150
+ }
1151
+ }
1152
+ this.storageClaims.set(key, { claim, view: view._id, partition });
1153
+ const field = this.partitionField;
1154
+ if (writer.partitionFields.has(field) && !isDeepStrictEqual(writer.partitionFields.get(field), partition)) {
1155
+ throw new ViewRunError(`the stored form of ${view.targetCollection} gives the field ${field} another value than the partition ${partition}`);
1156
+ }
1157
+ return {
1158
+ writer,
1159
+ filter: { ...writer.partitionFilter(), [field]: partition },
1160
+ partitionInContent: !writer.partitionFields.has(field) && !DOCUMENT_STORAGE_FIELDS.includes(field),
1161
+ };
1162
+ }
1163
+
814
1164
  /**
815
1165
  * The status of a view in the database (the current generation, and the previous one), and the
816
1166
  * time of the server: the times of the generations are times of the server.
@@ -852,7 +1202,7 @@ export class ViewMaterializer {
852
1202
  }
853
1203
 
854
1204
  /** The rows of the result, in groups of 1000. A failure of the pipeline, a row without `_id` and too many rows are a `ViewRunError`. */
855
- private async *resultGroups(view: MaterializedView, partition: string, generations: readonly ObjectId[], generation: ObjectId): AsyncGenerator<StoredRow[]> {
1205
+ private async *resultGroups(view: MaterializedView, partition: string, generations: readonly ObjectId[], generation: ObjectId, storage?: TargetPartition): AsyncGenerator<StoredRow[]> {
856
1206
  let pipeline: Document[];
857
1207
  try {
858
1208
  pipeline = this.scoped(view.pipeline, partition, view, generations);
@@ -874,6 +1224,14 @@ export class ViewMaterializer {
874
1224
  }
875
1225
  if (row === null) break;
876
1226
  if (++count > this.maxRows) throw new ViewRunError(`the result has more than ${this.maxRows} rows`);
1227
+ if (storage) {
1228
+ group.push(this.storageRowOf(row, partition, storage));
1229
+ if (group.length === WRITE_GROUP) {
1230
+ yield group;
1231
+ group = [];
1232
+ }
1233
+ continue;
1234
+ }
877
1235
  // The fields of the materializer, for a row that comes from a target: not in the content
878
1236
  const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
879
1237
  if (_id === undefined || _id === null) throw new ViewRunError("each row of the result needs an _id");
@@ -900,6 +1258,43 @@ export class ViewMaterializer {
900
1258
  }
901
1259
  }
902
1260
 
1261
+ /**
1262
+ * A row of the result as a document of a target in a stored form (roadmap CLI-81). The content is
1263
+ * the row without the fields of the materializer and of a storage: a pass-through row of a source
1264
+ * in a stored form loses its `_key`, `_kid`, `_kpf`, `_ord` and `_json`, and the encoding of the
1265
+ * storage makes them again from the row (`_json` from the content of the row). A field of the row
1266
+ * with the name of a partition field of the form takes the value of the partition of the target.
1267
+ * The numbers (`_seq`, `_ord`) come at the write.
1268
+ */
1269
+ private storageRowOf(row: Document, partition: string, storage: TargetPartition): StoredRow {
1270
+ const { _id: rowId, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
1271
+ let _id: string;
1272
+ try {
1273
+ _id = this.storageRowIdOf(rowId);
1274
+ } catch (error) {
1275
+ throw new ViewRunError(`the id of a row of a target in a stored form: ${String((error as Error)?.message ?? error)}`);
1276
+ }
1277
+ if (typeof _id !== "string") throw new ViewRunError(`storageRowIdOf must give a text, not ${typeof _id}`);
1278
+ const field = this.partitionField;
1279
+ delete content[field];
1280
+ for (const name of DOCUMENT_STORAGE_FIELDS) delete content[name];
1281
+ for (const [name, value] of storage.writer.partitionFields) {
1282
+ if (!(name in content)) continue;
1283
+ if (value === undefined) delete content[name];
1284
+ else content[name] = value;
1285
+ }
1286
+ if (storage.partitionInContent) content[field] = partition;
1287
+ let doc: Document;
1288
+ try {
1289
+ doc = storage.writer.encode(_id, { _id, ...content }, 0, 0);
1290
+ } catch (error) {
1291
+ throw new ViewRunError(`the row ${JSON.stringify(_id)}: ${String((error as Error)?.message ?? error)}`);
1292
+ }
1293
+ // A view that reads the target finds the row by the partition field, also in an item stored as JSON text
1294
+ if (doc[field] === undefined) doc[field] = partition;
1295
+ return { id: _id, key: idKey(doc["_id"]), hash: contentHash(doc), doc };
1296
+ }
1297
+
903
1298
  private replaceOf(row: StoredRow, partition: string): Document {
904
1299
  return { replaceOne: { filter: { _id: row.doc["_id"], [this.partitionField]: partition }, replacement: row.doc, upsert: true } };
905
1300
  }
@@ -918,6 +1313,7 @@ export class ViewMaterializer {
918
1313
  const generation = current ?? new ObjectId();
919
1314
  const session = this.db!.client.startSession();
920
1315
  let changed = current === undefined;
1316
+ const hint = { changed: [] as unknown[], deleted: [] as unknown[] };
921
1317
  try {
922
1318
  session.startTransaction();
923
1319
  // In the transaction: a change of generation by another process since the start supersedes this pass
@@ -930,8 +1326,10 @@ export class ViewMaterializer {
930
1326
  .find({ _id: { $in: group.map((row) => row.doc["_id"]) }, [this.partitionField]: partition } as Document, { projection: { [VIEW_HASH_FIELD]: 1 }, session })
931
1327
  .toArray();
932
1328
  const hashes = new Map(stored.map((doc) => [idKey(doc["_id"]), doc[VIEW_HASH_FIELD]]));
933
- const writes = group.filter((row) => hashes.get(row.key) !== row.hash).map((row) => this.replaceOf(row, partition));
1329
+ const rows = group.filter((row) => hashes.get(row.key) !== row.hash);
1330
+ const writes = rows.map((row) => this.replaceOf(row, partition));
934
1331
  for (const row of group) seen.add(row.key);
1332
+ for (const row of rows) hint.changed.push(row.id);
935
1333
  if (writes.length > 0) {
936
1334
  await target.bulkWrite(writes as never, { ordered: false, session });
937
1335
  changed = true;
@@ -947,6 +1345,7 @@ export class ViewMaterializer {
947
1345
  }
948
1346
  for (const group of groups(stale)) await target.deleteMany({ [this.partitionField]: partition, _id: { $in: group } } as Document, { session });
949
1347
  if (stale.length > 0) changed = true;
1348
+ for (const storedId of stale) hint.deleted.push(this.rowIdOf(storedId));
950
1349
  if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
951
1350
  throw new PassSuperseded("another process named the first generation of the view");
952
1351
  }
@@ -957,9 +1356,35 @@ export class ViewMaterializer {
957
1356
  } finally {
958
1357
  await session.endSession().catch(() => undefined);
959
1358
  }
1359
+ if (hint.changed.length > 0 || hint.deleted.length > 0) this.publishHint(view, partition, hint);
960
1360
  return changed;
961
1361
  }
962
1362
 
1363
+ /**
1364
+ * This method publishes the hint of a run of a target that is not in a stored form: one hint
1365
+ * for the run, with the ids of the changed rows and of the removed rows. Without ids (a change
1366
+ * of generation), with an id that is not a text, or with more than `MAX_HINT_IDS` ids, it is a
1367
+ * reset hint. A failure of the publisher goes to the log: the run does not fail.
1368
+ */
1369
+ private publishHint(view: MaterializedView, partition: string, ids: { changed: readonly unknown[]; deleted: readonly unknown[] } | undefined): void {
1370
+ const publish = this.options.publish;
1371
+ if (!publish) return;
1372
+ const fail = (error: unknown): void => this.log("error", `the hint of ${view._id} failed: ${String((error as Error)?.message ?? error)}`);
1373
+ try {
1374
+ const topic = this.options.hintTopic ? this.options.hintTopic(view, partition) : collectionChangeTopic(view._id);
1375
+ if (topic === undefined) return;
1376
+ const texts = (values: readonly unknown[]): values is readonly string[] => values.every((value) => typeof value === "string");
1377
+ const event: CollectionChangeEvent =
1378
+ ids && ids.changed.length + ids.deleted.length <= MAX_HINT_IDS && texts(ids.changed) && texts(ids.deleted)
1379
+ ? { changed: [...new Set(ids.changed)], deleted: [...new Set(ids.deleted)] }
1380
+ : { changed: [], deleted: [], reset: true };
1381
+ const result = typeof publish === "function" ? publish(topic, event) : publish.emit(topic, event);
1382
+ if (result instanceof Promise) result.catch(fail);
1383
+ } catch (error) {
1384
+ fail(error);
1385
+ }
1386
+ }
1387
+
963
1388
  /**
964
1389
  * A run in groups, outside a transaction: a whole new generation, then the change of the status to
965
1390
  * it. The readers of `viewFilter` move to it at once. The rows of no generation go at once: no
@@ -995,6 +1420,8 @@ export class ViewMaterializer {
995
1420
  await this.releaseLease(view, partition, owner).catch(() => undefined);
996
1421
  throw error;
997
1422
  }
1423
+ // The readers of the view moved to the new generation at once: a gap, so the subscribers read the view in full
1424
+ this.publishHint(view, partition, undefined);
998
1425
  await target.deleteMany({ [this.partitionField]: partition, [VIEW_GENERATION_FIELD]: { $exists: false } });
999
1426
  // At the end of the grace time, the previous generation goes. The time of the server decides it, so a timer that ends early removes nothing
1000
1427
  const key = this.keyOf(partition, view._id);
@@ -1010,6 +1437,202 @@ export class ViewMaterializer {
1010
1437
  this.retries.add(cancel);
1011
1438
  }
1012
1439
 
1440
+ /**
1441
+ * The rows of a group that a run writes in a target in a stored form: each row whose stored
1442
+ * document is absent, is a tombstone, or has other content (the numbers do not count). A row
1443
+ * keeps the place (`_ord`) of its live stored document. Two rows with the same `_id` in a result
1444
+ * are an error of the view.
1445
+ */
1446
+ private async storageWrites(target: Collection, storage: TargetPartition, group: readonly StoredRow[], seen: Set<string>, session: ClientSession | undefined): Promise<StorageWrite[]> {
1447
+ for (const row of group) {
1448
+ if (seen.has(row.key)) throw new ViewRunError(`the result has two rows with the _id ${JSON.stringify(row.id)}`);
1449
+ seen.add(row.key);
1450
+ }
1451
+ const stored = await target
1452
+ .find({ _id: { $in: group.map((row) => row.doc["_id"]) }, ...storage.writer.partitionFilter() } as Document, session ? { session } : {})
1453
+ .toArray();
1454
+ const byKey = new Map(stored.map((doc) => [idKey(doc["_id"]), doc]));
1455
+ const writes: StorageWrite[] = [];
1456
+ for (const row of group) {
1457
+ const doc = byKey.get(row.key);
1458
+ const live = doc !== undefined && doc[TOMBSTONE_FIELD] !== true;
1459
+ if (live && contentHash(doc) === row.hash) continue;
1460
+ writes.push({ row, ord: live && typeof doc["_ord"] === "number" ? doc["_ord"] : undefined });
1461
+ }
1462
+ return writes;
1463
+ }
1464
+
1465
+ /**
1466
+ * The live documents of the partition of a view in a target in a stored form that the result
1467
+ * does not have. A document whose id the result does not have becomes the tombstone of that id. A
1468
+ * document that tells no id, or the id of a row of the result (under another stored `_id`),
1469
+ * becomes a tombstone without `_key`: no reader reads a delete of an item from it. More than
1470
+ * `limit` documents is an error, so a transaction goes in groups.
1471
+ */
1472
+ private async *storageRemovals(
1473
+ target: Collection,
1474
+ storage: TargetPartition,
1475
+ seen: ReadonlySet<string>,
1476
+ session: ClientSession | undefined,
1477
+ limit = Number.POSITIVE_INFINITY
1478
+ ): AsyncGenerator<StorageRemoval[]> {
1479
+ let group: StorageRemoval[] = [];
1480
+ let count = 0;
1481
+ const cursor = target.find({ ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } } as Document, { projection: { _id: 1, _key: 1 }, ...(session ? { session } : {}) });
1482
+ try {
1483
+ for await (const doc of cursor) {
1484
+ if (seen.has(idKey(doc["_id"]))) continue;
1485
+ if (++count > limit) throw new Error(`more than ${limit} rows to remove`);
1486
+ let id = storage.writer.idOfDoc(doc);
1487
+ try {
1488
+ if (id !== undefined && seen.has(idKey(storage.writer.storedId(id)))) id = undefined;
1489
+ } catch {
1490
+ id = undefined;
1491
+ }
1492
+ group.push({ storedId: doc["_id"], id });
1493
+ if (group.length === WRITE_GROUP) {
1494
+ yield group;
1495
+ group = [];
1496
+ }
1497
+ }
1498
+ if (group.length > 0) yield group;
1499
+ } finally {
1500
+ await cursor.close().catch(() => undefined);
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * The writes of a target in a stored form with their numbers from `first`: the tombstones first,
1506
+ * then the rows. A new row takes its own number as its place. The filter of a write holds the
1507
+ * partition: a row of another partition of the view fails with a duplicate key.
1508
+ */
1509
+ private storageOperations(storage: TargetPartition, partition: string, writes: readonly StorageWrite[], removals: readonly StorageRemoval[], first: number): AnyBulkWriteOperation<Document>[] {
1510
+ const field = this.partitionField;
1511
+ const operations: AnyBulkWriteOperation<Document>[] = [];
1512
+ let seq = first;
1513
+ for (const removal of removals) {
1514
+ const number = seq++;
1515
+ const tomb: Document = removal.id === undefined ? { _id: removal.storedId, _seq: number, [TOMBSTONE_FIELD]: true } : storage.writer.tombstone(removal.id, number);
1516
+ const replacement: Document = { ...tomb, _id: removal.storedId };
1517
+ for (const [name, value] of storage.writer.partitionFields) if (value !== undefined) replacement[name] = value;
1518
+ replacement[field] = partition;
1519
+ operations.push({ replaceOne: { filter: { _id: removal.storedId, ...storage.filter, [TOMBSTONE_FIELD]: { $ne: true } } as Document, replacement } });
1520
+ }
1521
+ for (const { row, ord } of writes) {
1522
+ const number = seq++;
1523
+ const filter = { _id: row.doc["_id"], ...storage.writer.partitionFilter(), $or: [{ [field]: partition }, { [TOMBSTONE_FIELD]: true }, { [field]: { $exists: false } }] };
1524
+ operations.push({ replaceOne: { filter, replacement: { ...row.doc, _seq: number, _ord: ord ?? number }, upsert: true } });
1525
+ }
1526
+ return operations;
1527
+ }
1528
+
1529
+ /**
1530
+ * A run of a target in a stored form in one transaction (roadmap CLI-81): the rows that changed,
1531
+ * and a tombstone for each row that the result does not have. The numbers are pending numbers of
1532
+ * the storage: the run takes them after its reads, writes with them in the transaction, and ends
1533
+ * them after the commit. A late run moves its documents to new numbers. The transaction does not
1534
+ * write the counters, so the writes of the storage do not conflict with it.
1535
+ */
1536
+ private async runStorageInTransaction(
1537
+ view: MaterializedView,
1538
+ partition: string,
1539
+ target: Collection,
1540
+ storage: TargetPartition,
1541
+ current: ObjectId | undefined,
1542
+ generations: readonly ObjectId[]
1543
+ ): Promise<boolean> {
1544
+ const generation = current ?? new ObjectId();
1545
+ const db = this.db!;
1546
+ const session = db.client.startSession();
1547
+ let count = 0;
1548
+ let removed = 0;
1549
+ try {
1550
+ session.startTransaction();
1551
+ const fresh = await db.collection(this.viewsName).findOne({ _id: view._id, [this.partitionField]: partition } as Document, { projection: { [this.statusField]: 1 }, session });
1552
+ const now = (fresh?.[this.statusField] as Partial<MaterializedViewStatus> | undefined)?.generation;
1553
+ if (!(now === undefined ? current === undefined : current !== undefined && now.equals(current))) throw new PassSuperseded("another process changed the generation of the view");
1554
+ const seen = new Set<string>();
1555
+ const writes: StorageWrite[] = [];
1556
+ for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
1557
+ writes.push(...(await this.storageWrites(target, storage, group, seen, session)));
1558
+ if (writes.length > this.maxTransactionRows) throw new Error(`more than ${this.maxTransactionRows} rows to write`);
1559
+ }
1560
+ const removals: StorageRemoval[] = [];
1561
+ for await (const group of this.storageRemovals(target, storage, seen, session, this.maxTransactionRows - writes.length)) removals.push(...group);
1562
+ count = writes.length + removals.length;
1563
+ removed = removals.length;
1564
+ const commit = async (first: number): Promise<void> => {
1565
+ for (const operations of groups(this.storageOperations(storage, partition, writes, removals, first))) await target.bulkWrite(operations, { ordered: true, session });
1566
+ if (!current && !(await this.setGeneration(view, partition, generation, { expected: undefined, session }))) {
1567
+ throw new PassSuperseded("another process named the first generation of the view");
1568
+ }
1569
+ await session.commitTransaction();
1570
+ };
1571
+ if (count === 0) await commit(0);
1572
+ else await storage.writer.withPendingNumbers(db, count, commit);
1573
+ } catch (error) {
1574
+ if (session.inTransaction()) await session.abortTransaction().catch(() => undefined);
1575
+ throw error;
1576
+ } finally {
1577
+ await session.endSession().catch(() => undefined);
1578
+ }
1579
+ if (removed > 0) await storage.writer.prune(db);
1580
+ return count > 0 || current === undefined;
1581
+ }
1582
+
1583
+ /**
1584
+ * A run of a target in a stored form in groups, outside a transaction (roadmap CLI-81): the rows
1585
+ * of each group that changed, then the tombstones of the rows that the result does not have, with
1586
+ * the pending numbers of the storage. The run writes in place, so a reader can see a part of it.
1587
+ * It holds the lease of the view, and renews it after each group. The first run names a
1588
+ * generation in the status of the view: the mark of a view that ran.
1589
+ */
1590
+ private async runStorageInGroups(
1591
+ view: MaterializedView,
1592
+ partition: string,
1593
+ target: Collection,
1594
+ storage: TargetPartition,
1595
+ current: ObjectId | undefined,
1596
+ generations: readonly ObjectId[]
1597
+ ): Promise<boolean> {
1598
+ const generation = current ?? new ObjectId();
1599
+ const owner = new ObjectId().toHexString();
1600
+ const db = this.db!;
1601
+ if (!(await this.takeLease(view, partition, owner, generation))) throw new PassSuperseded("a pass of another process holds the view");
1602
+ let changed = current === undefined;
1603
+ let removed = false;
1604
+ try {
1605
+ const seen = new Set<string>();
1606
+ for await (const group of this.resultGroups(view, partition, generations, generation, storage)) {
1607
+ const writes = await this.storageWrites(target, storage, group, seen, undefined);
1608
+ if (writes.length > 0) {
1609
+ changed = true;
1610
+ await storage.writer.withPendingNumbers(db, writes.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, writes, [], first), { ordered: true }));
1611
+ }
1612
+ if (!(await this.renewLease(view, partition, owner))) throw new PassSuperseded("the pass lost its lease");
1613
+ }
1614
+ for await (const removals of this.storageRemovals(target, storage, seen, undefined)) {
1615
+ changed = true;
1616
+ removed = true;
1617
+ await storage.writer.withPendingNumbers(db, removals.length, (first) => target.bulkWrite(this.storageOperations(storage, partition, [], removals, first), { ordered: true }));
1618
+ if (!(await this.renewLease(view, partition, owner))) throw new PassSuperseded("the pass lost its lease");
1619
+ }
1620
+ if (current === undefined) {
1621
+ if (!(await this.setGeneration(view, partition, generation, { expected: undefined, owner }))) {
1622
+ throw new PassSuperseded("the pass lost its lease, or another process named the first generation of the view");
1623
+ }
1624
+ } else {
1625
+ await this.releaseLease(view, partition, owner);
1626
+ }
1627
+ } catch (error) {
1628
+ await this.releaseLease(view, partition, owner).catch(() => undefined);
1629
+ throw error;
1630
+ } finally {
1631
+ if (removed) await storage.writer.prune(db).catch((error: unknown) => this.log("error", `${view._id}: the prune of the tombstones failed: ${String((error as Error)?.message ?? error)}`));
1632
+ }
1633
+ return changed;
1634
+ }
1635
+
1013
1636
  /** True while the view is the one that the materializer runs for this key. */
1014
1637
  private isCurrentView(key: string, view: MaterializedView): boolean {
1015
1638
  return this.running.get(key)?.view === view;