@procedures/mongo 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +27 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/index.d.ts +3 -1
  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 +162 -3
  16. package/dist/storage/document-storage.d.ts.map +1 -1
  17. package/dist/storage/document-storage.js +325 -7
  18. package/dist/storage/document-storage.js.map +1 -1
  19. package/dist/storage/index.d.ts +3 -1
  20. package/dist/storage/index.d.ts.map +1 -1
  21. package/dist/storage/index.js +3 -1
  22. package/dist/storage/index.js.map +1 -1
  23. package/dist/storage/materializer.d.ts.map +1 -1
  24. package/dist/storage/materializer.js +7 -0
  25. package/dist/storage/materializer.js.map +1 -1
  26. package/dist/storage/own-writes.d.ts +51 -0
  27. package/dist/storage/own-writes.d.ts.map +1 -0
  28. package/dist/storage/own-writes.js +130 -0
  29. package/dist/storage/own-writes.js.map +1 -0
  30. package/dist/storage/storage-hints.d.ts +230 -0
  31. package/dist/storage/storage-hints.d.ts.map +1 -0
  32. package/dist/storage/storage-hints.js +876 -0
  33. package/dist/storage/storage-hints.js.map +1 -0
  34. package/package.json +3 -3
  35. package/src/index.ts +4 -0
  36. package/src/module.ts +5 -5
  37. package/src/procedures/input-schemas.ts +29 -2
  38. package/src/storage/document-storage.ts +394 -7
  39. package/src/storage/index.ts +4 -0
  40. package/src/storage/materializer.ts +7 -0
  41. package/src/storage/own-writes.ts +157 -0
  42. package/src/storage/storage-hints.ts +971 -0
@@ -93,7 +93,7 @@
93
93
 
94
94
  import { randomUUID } from "node:crypto";
95
95
  import { isDeepStrictEqual } from "node:util";
96
- import type { AnyBulkWriteOperation, ClientSession, Collection, Db, Document, Filter, MongoClient } from "mongodb";
96
+ import { BSON, type AnyBulkWriteOperation, type ClientSession, type Collection, type Db, type Document, type Filter, type MongoClient } from "mongodb";
97
97
  import {
98
98
  VersionConflictError,
99
99
  applyPatch,
@@ -115,6 +115,7 @@ import {
115
115
  } from "@procedures/collections";
116
116
  import { optionalMs, toMs, type Duration } from "@procedures/client";
117
117
  import { ensureConnection } from "../connection.js";
118
+ import { noteOwnNumbers, notePrunedIds } from "./own-writes.js";
118
119
  import { boundPipeline, cutResults, DEFAULT_PIPELINE_LIMITS, scopePipeline, type PipelineLimits } from "./pipeline-scope.js";
119
120
 
120
121
  /** The fields of the storage in a stored document. */
@@ -419,6 +420,132 @@ export function decodeStoredDocument<T = Document>(doc: Document, options: Decod
419
420
  return { id, partition, item: itemOf(doc, id, entries, options.addId ?? true) as T };
420
421
  }
421
422
 
423
+ /**
424
+ * This function gives the version of a raw stored document of a `MongoDocumentStorage`: the
425
+ * version that `storage.versionOf(id)` gives for the same document (roadmap CLI-77). The storage
426
+ * uses it too, so the two never differ.
427
+ *
428
+ * - The version is the sequence number of the last write through the storage (`_seq`), as text.
429
+ * - A document without `_seq` (an old document, or one that a raw replace wrote) has the version `0`.
430
+ * - A tombstone, a value that is not a plain object, and a document whose id neither `idOf` nor
431
+ * `_key` tells give `undefined`: they are no items, as for `decodeStoredDocument`.
432
+ *
433
+ * @param doc - The stored document, for example the `fullDocument` of a change event
434
+ * @param form - The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`)
435
+ * @param partition - The partition of the document. The default is `form.partitionOf(doc)`.
436
+ *
437
+ * @example
438
+ * ```typescript
439
+ * const version = versionOfStored(change.fullDocument, notes.form); // the same as await notes.versionOf(id)
440
+ * ```
441
+ */
442
+ export function versionOfStored(doc: Document, form: StoredForm = DEFAULT_STORED_FORM, partition?: string): string | undefined {
443
+ if (!isPlainObject(doc) || doc[TOMB] === true) return undefined;
444
+ const place = partition ?? form.partitionOf(doc);
445
+ if (typeof form.idOf(doc["_id"], place) !== "string" && typeof doc[KEY] !== "string") return undefined;
446
+ return seqVersion(doc);
447
+ }
448
+
449
+ /** The version of the `_seq` of a stored document: the number as text, or `0` without it. */
450
+ function seqVersion(doc: Document): string {
451
+ const seq = doc[SEQ];
452
+ return typeof seq === "number" ? String(seq) : "0";
453
+ }
454
+
455
+ /**
456
+ * The parts of a storage that `watchStorageChanges` reads (roadmap CLI-77). The package does not
457
+ * export this type: it is for the modules of the storage.
458
+ */
459
+ export interface StorageChangeSource {
460
+ /** The database of the storage. */
461
+ db(): Promise<Db>;
462
+ /** The MongoDB collection of the items. */
463
+ collection: string;
464
+ /** The collection of the sequence counters. */
465
+ counters: string;
466
+ /** The stored form. */
467
+ form: StoredForm;
468
+ /** The partition of the storage, or undefined for the storage of the whole collection. */
469
+ partition: string | undefined;
470
+ /** This method adopts a raw write (D39): see `RawStorageWrite`. */
471
+ adopt(raw: RawStorageWrite): Promise<RawAdoption>;
472
+ /**
473
+ * This method adopts several raw writes: the writes of each partition take one block of numbers.
474
+ * A partition whose writes fail gives `{ error }` for each of its writes.
475
+ */
476
+ adoptAll(raws: readonly RawStorageWrite[]): Promise<RawAdoptionResult[]>;
477
+ /**
478
+ * One page of the documents without `_seq` (the sweep of the raw writes), in the order of `_id`,
479
+ * after the `_id` of `after` (from the start without it).
480
+ */
481
+ rawDocuments(after: { readonly id: unknown } | undefined, limit: number): Promise<Document[]>;
482
+ /** The stored documents of these stored `_id`s: the watcher adopts a raw write again after a full queue or a failure. */
483
+ storedDocuments(ids: readonly unknown[]): Promise<Document[]>;
484
+ /**
485
+ * The partitions that the storage knows: each partition with a floor document in the counters
486
+ * (the first write of a storage and each prune write one), and each partition of
487
+ * `forPartition()` in this process. It rejects when the counters cannot be read.
488
+ */
489
+ partitions(): Promise<Array<string | undefined>>;
490
+ }
491
+
492
+ /**
493
+ * A raw write of the collection (D39): a change event of a document that the storage did not write.
494
+ * The storage adopts it: one conditional write gives the document a new `_seq` through the path of
495
+ * each write (the transaction, or the watermark and the late move), so `changes(since)` has it.
496
+ *
497
+ * - The filter is the stored `_id` and the `_seq` that the event saw (or no `_seq`): when the
498
+ * document changed since (a later adoption, or a write of the storage), the adoption does
499
+ * nothing. Thus one adoption wins.
500
+ * - The adoption sets `_seq`, and the storage fields and partition fields that the document does
501
+ * not have: from the image before the change, else from the stored `_id` (`form.idOf()`,
502
+ * `form.partitionOf()`). It never changes a field of the item.
503
+ * - A hard delete (no `after`) needs the image before the change: the adoption inserts a tombstone
504
+ * with a new `_seq`, and the insert fails when the `_id` exists again.
505
+ * - The numbers of an adoption are never noted as numbers of this process: its change event is
506
+ * the hint of the raw write.
507
+ */
508
+ export interface RawStorageWrite {
509
+ /** The stored `_id` of the document (`documentKey._id`). */
510
+ readonly storedId: unknown;
511
+ /** The `_seq` of the state that the event saw, or undefined for a document without `_seq`. */
512
+ readonly seq: number | undefined;
513
+ /** The document after the write, or undefined for a hard delete. */
514
+ readonly after?: Document | undefined;
515
+ /** The image of the document before the change, when the collection keeps it (`changeStreamPreAndPostImages`). */
516
+ readonly before?: Document | undefined;
517
+ }
518
+
519
+ /** The result of an adoption: `adopted`, `lost` (the document changed since: another adoption or write won), or `unknown` (no image and no stored `_id` tells the id and the partition). */
520
+ export type RawAdoption = "adopted" | "lost" | "unknown";
521
+
522
+ /** True when two values have the same BSON. */
523
+ function sameBson(a: unknown, b: unknown): boolean {
524
+ try {
525
+ return Buffer.from(BSON.serialize({ v: a })).equals(Buffer.from(BSON.serialize({ v: b })));
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+
531
+ /** The result of one raw write of `adoptAll()`: a `RawAdoption`, or the error of the write of its partition. */
532
+ export type RawAdoptionResult = RawAdoption | { readonly error: unknown };
533
+
534
+ /** The most floor documents that `partitions()` reads. */
535
+ const MAX_KNOWN_PARTITIONS = 10_000;
536
+
537
+ function escapeRegex(text: string): string {
538
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
539
+ }
540
+
541
+ /** The reader of the parts of a storage. The class sets it, so it can read its private fields. */
542
+ let sourceOfStorage: <T>(storage: MongoDocumentStorage<T>) => StorageChangeSource;
543
+
544
+ /** This function gives the parts of a storage that `watchStorageChanges` reads. The package does not export it. */
545
+ export function storageChangeSource<T>(storage: MongoDocumentStorage<T>): StorageChangeSource {
546
+ return sourceOfStorage(storage);
547
+ }
548
+
422
549
  /** True for a plain object. */
423
550
  function isPlainObject(value: unknown): value is Record<string, unknown> {
424
551
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -557,8 +684,25 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
557
684
  /** The fields that only the storage writes: its fields and the partition fields. */
558
685
  private readonly reserved: ReadonlySet<string>;
559
686
  private indexes: Promise<void> | undefined;
687
+ /** True after this storage wrote (or found) the floor document of its partition (roadmap O66). A failed write leaves it false. */
688
+ private floorNoted = false;
560
689
  private closed = false;
561
690
 
691
+ static {
692
+ sourceOfStorage = <S>(storage: MongoDocumentStorage<S>): StorageChangeSource => ({
693
+ db: () => storage.database(),
694
+ collection: storage.collectionName,
695
+ counters: storage.countersName,
696
+ form: storage.form,
697
+ partition: storage.partition,
698
+ adopt: (raw) => storage.adoptRaw(raw),
699
+ adoptAll: (raws) => storage.adoptRawAll(raws),
700
+ rawDocuments: (after, limit) => storage.rawDocuments(after, limit),
701
+ storedDocuments: (ids) => storage.storedDocuments(ids),
702
+ partitions: () => storage.knownPartitions(),
703
+ });
704
+ }
705
+
562
706
  constructor(options: MongoDocumentStorageOptions<T>, partitions?: Map<string, MongoDocumentStorage<T>>) {
563
707
  if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
564
708
  throw new Error("A partition must be a text without the character U+001F");
@@ -804,9 +948,17 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
804
948
  /**
805
949
  * This method takes numbers and writes with them. On a replica set, the two are one
806
950
  * transaction. Else the numbers stay pending until the write ends, and a late write moves its
807
- * documents to new numbers.
951
+ * documents to new numbers. With `own` false (an adoption of a raw write, D39), a watcher of this
952
+ * process does not take the numbers as its own.
808
953
  */
809
- private async withNumbers<R>(db: Db, collection: Collection<Stored>, count: number, write: (first: number, session?: ClientSession) => Promise<R>): Promise<R> {
954
+ private async withNumbers<R>(
955
+ db: Db,
956
+ collection: Collection<Stored>,
957
+ count: number,
958
+ write: (first: number, session?: ClientSession) => Promise<R>,
959
+ own = true
960
+ ): Promise<R> {
961
+ const noteOwn = own ? noteOwnNumbers : (): void => undefined;
810
962
  const client = await this.transactionClient(db);
811
963
  if (client) {
812
964
  const session = client.startSession();
@@ -818,6 +970,8 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
818
970
  { upsert: true, returnDocument: "after", session }
819
971
  )) as unknown as Counter | null;
820
972
  if (!counter) throw new Error(`The sequence of ${this.collectionName} is not available`);
973
+ // A watcher of the collection in this process skips the events of these numbers (roadmap CLI-77)
974
+ noteOwn(db, this.collectionName, counter.seq - count + 1, count);
821
975
  return write(counter.seq - count + 1, session);
822
976
  });
823
977
  } finally {
@@ -825,6 +979,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
825
979
  }
826
980
  }
827
981
  let { first } = await this.allocate(db, count);
982
+ noteOwn(db, this.collectionName, first, count);
828
983
  let result: R;
829
984
  try {
830
985
  result = await write(first);
@@ -836,6 +991,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
836
991
  if (moves >= MAX_MOVES) throw new Error(`A write of ${this.collectionName} stayed late after ${MAX_MOVES} moves to new numbers`);
837
992
  // Late: a reader can have passed these numbers. The write takes new ones.
838
993
  const next = (await this.allocate(db, count)).first;
994
+ noteOwn(db, this.collectionName, next, count);
839
995
  await this.move(collection, first, count, next);
840
996
  first = next;
841
997
  }
@@ -880,6 +1036,31 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
880
1036
  return `floor${SEPARATOR}${this.collectionName}${SEPARATOR}${this.partition ?? ""}`;
881
1037
  }
882
1038
 
1039
+ /**
1040
+ * This method writes the floor document of the partition once for this storage, before its first
1041
+ * write (roadmap O66). Then `knownPartitions()` of each process finds the partition, also without
1042
+ * a prune, and a gap of `watchStorageChanges()` sends it a reset hint.
1043
+ *
1044
+ * - The floor `0` is neutral: `getChangesSince` gives a full result only for a version below the
1045
+ * floor, and a version is never below `0`. A missing floor document also reads as `0`. So the
1046
+ * document changes no result of the change log.
1047
+ * - `$setOnInsert` writes only a new document: a floor of a prune (`$max`) stays as it is.
1048
+ * - A duplicate key (another process inserted the same document first) is a success.
1049
+ * - The upsert runs in the queue of the writes, before the task, so never in the transaction of
1050
+ * `withNumbers()`: it adds no write conflict to the transaction of the user, and a transaction
1051
+ * that tries again does not write it again.
1052
+ * - A failure does not fail the write of the user. The flag stays false, so the next write tries again.
1053
+ */
1054
+ private async noteFloor(db: Db): Promise<void> {
1055
+ if (this.floorNoted) return;
1056
+ try {
1057
+ await db.collection<CounterDoc>(this.countersName).updateOne({ _id: this.floorId() }, { $setOnInsert: { floor: 0 } }, { upsert: true });
1058
+ this.floorNoted = true;
1059
+ } catch (error) {
1060
+ if (isDuplicateKey(error)) this.floorNoted = true;
1061
+ }
1062
+ }
1063
+
883
1064
  /** This method drops the oldest tombstones of the partition over the limit, and keeps the floor of the log. */
884
1065
  private async pruneTombstones(db: Db, items: Collection<Stored>): Promise<void> {
885
1066
  const count = await items.countDocuments({ ...this.partitionFilter(), [TOMB]: true } as Filter<Stored>);
@@ -891,6 +1072,8 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
891
1072
  .limit(excess)
892
1073
  .toArray();
893
1074
  if (oldest.length === 0) return;
1075
+ // A watcher of this process reads the delete of a pruned tombstone without the image before the change (roadmap CLI-77)
1076
+ notePrunedIds(db, this.collectionName, oldest.map((doc) => doc._id));
894
1077
  await items.deleteMany({ _id: { $in: oldest.map((doc) => doc._id) } } as Filter<Stored>);
895
1078
  const floor = Math.max(...oldest.map((doc) => Number(doc[SEQ])));
896
1079
  await db.collection<CounterDoc>(this.countersName).updateOne({ _id: this.floorId() }, { $max: { floor } }, { upsert: true });
@@ -1013,13 +1196,14 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1013
1196
 
1014
1197
  /**
1015
1198
  * The version of an item: the sequence number of its last write, or undefined for a missing id.
1016
- * An item without a write through the storage (no `_seq`) has the version `0`.
1199
+ * An item without a write through the storage (no `_seq`) has the version `0`. It is the version
1200
+ * of `versionOfStored()` of the stored document (roadmap CLI-77): the two share one rule.
1017
1201
  */
1018
1202
  async versionOf(id: string): Promise<string | undefined> {
1019
1203
  const { items } = await this.collection();
1020
1204
  const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { [SEQ]: 1 } });
1021
- if (!doc) return undefined;
1022
- return typeof doc[SEQ] === "number" ? String(doc[SEQ]) : "0";
1205
+ // The filter finds the document by the stored `_id` of the id in the partition: it is the item
1206
+ return doc ? seqVersion(doc) : undefined;
1023
1207
  }
1024
1208
 
1025
1209
  async getChangesSince(since?: string): Promise<StorageChanges<T>> {
@@ -1062,7 +1246,11 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1062
1246
 
1063
1247
  private async write<R>(task: (db: Db, items: Collection<Stored>) => Promise<R>): Promise<R> {
1064
1248
  const { db, items } = await this.collection();
1065
- return enqueue(db, this.collectionName, () => task(db, items));
1249
+ return enqueue(db, this.collectionName, async () => {
1250
+ // The floor document of the partition, outside each transaction of the task (roadmap O66)
1251
+ await this.noteFloor(db);
1252
+ return task(db, items);
1253
+ });
1066
1254
  }
1067
1255
 
1068
1256
  async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
@@ -1188,6 +1376,205 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
1188
1376
  await this.pruneTombstones(db, collection);
1189
1377
  }
1190
1378
 
1379
+ // ===========================================================================
1380
+ // The raw writes (D39, roadmap CLI-77)
1381
+ // ===========================================================================
1382
+
1383
+ /**
1384
+ * This method adopts a raw write: one conditional write through the path of each write gives the
1385
+ * document a new `_seq` (see `RawStorageWrite`). It is for `watchStorageChanges()`, and it is no
1386
+ * general setter of `_seq`.
1387
+ */
1388
+ private async adoptRaw(raw: RawStorageWrite): Promise<RawAdoption> {
1389
+ const [result] = await this.adoptRawAll([raw]);
1390
+ if (result === undefined || typeof result !== "string") throw (result as { error: unknown } | undefined)?.error;
1391
+ return result;
1392
+ }
1393
+
1394
+ /**
1395
+ * This method adopts several raw writes. The writes of each partition take one block of numbers
1396
+ * through the path of each write. A number of a write that does not win stays unused.
1397
+ */
1398
+ private async adoptRawAll(raws: readonly RawStorageWrite[]): Promise<RawAdoptionResult[]> {
1399
+ const results: RawAdoptionResult[] = raws.map(() => "unknown");
1400
+ const groups = new Map<MongoDocumentStorage<T>, Array<{ index: number; raw: RawStorageWrite; id: string }>>();
1401
+ raws.forEach((raw, index) => {
1402
+ const target = this.adoptionTarget(raw);
1403
+ if (!target) return;
1404
+ let group = groups.get(target.storage);
1405
+ if (!group) groups.set(target.storage, (group = []));
1406
+ group.push({ index, raw, id: target.id });
1407
+ });
1408
+ for (const [storage, group] of groups) {
1409
+ try {
1410
+ const done = await storage.adoptGroup(group);
1411
+ group.forEach((entry, position) => (results[entry.index] = done[position] ?? "lost"));
1412
+ } catch (error) {
1413
+ for (const entry of group) results[entry.index] = { error };
1414
+ }
1415
+ }
1416
+ return results;
1417
+ }
1418
+
1419
+ /** The adoption of raw writes of this partition, with one block of numbers. */
1420
+ private async adoptGroup(group: ReadonlyArray<{ raw: RawStorageWrite; id: string }>): Promise<RawAdoption[]> {
1421
+ return this.write(async (db, collection) => {
1422
+ const results = await this.withNumbers(
1423
+ db,
1424
+ collection,
1425
+ group.length,
1426
+ async (first, session): Promise<RawAdoption[]> => {
1427
+ const out: RawAdoption[] = [];
1428
+ for (const [position, { raw, id }] of group.entries()) {
1429
+ const seq = first + position;
1430
+ out.push(raw.after === undefined ? await this.adoptDelete(collection, raw, id, seq, session) : await this.adoptWrite(collection, raw, raw.after, id, seq, session));
1431
+ }
1432
+ return out;
1433
+ },
1434
+ false
1435
+ );
1436
+ if (results.some((result, position) => result === "adopted" && group[position]!.raw.after === undefined)) await this.pruneTombstones(db, collection);
1437
+ return results;
1438
+ });
1439
+ }
1440
+
1441
+ /** A raw hard delete: a tombstone with the id and the partition of the image before the change. */
1442
+ private async adoptDelete(collection: Collection<Stored>, raw: RawStorageWrite, id: string, seq: number, session: ClientSession | undefined): Promise<RawAdoption> {
1443
+ // In a transaction, a duplicate key would end the transaction: the read comes first
1444
+ if (session && (await collection.findOne({ _id: raw.storedId } as Filter<Stored>, { projection: { _id: 1 }, session }))) return "lost";
1445
+ const tomb: Stored = { _id: raw.storedId, [KEY]: id, [SEQ]: seq, [TOMB]: true };
1446
+ this.putPartition(tomb);
1447
+ try {
1448
+ await collection.insertOne(tomb, session ? { session } : {});
1449
+ return "adopted";
1450
+ } catch (error) {
1451
+ // A raw insert of the same _id came after the delete: that insert is the newer state
1452
+ if (isDuplicateKey(error)) return "lost";
1453
+ throw error;
1454
+ }
1455
+ }
1456
+
1457
+ /** A raw insert, replace or update: one conditional write on the exact state of the event. */
1458
+ private async adoptWrite(collection: Collection<Stored>, raw: RawStorageWrite, after: Document, id: string, seq: number, session: ClientSession | undefined): Promise<RawAdoption> {
1459
+ const fields = this.restoredFields(raw, after, id);
1460
+ // The exact state of the event: the same `_seq` (or none), and no field that the adoption sets
1461
+ const filter: Document = { _id: raw.storedId, [SEQ]: raw.seq ?? { $exists: false } };
1462
+ for (const name of Object.keys(fields)) filter[name] = { $exists: false };
1463
+ const result = await collection.updateOne(filter as Filter<Stored>, { $set: { ...fields, [SEQ]: seq } }, session ? { session } : {});
1464
+ return result.matchedCount === 1 ? "adopted" : "lost";
1465
+ }
1466
+
1467
+ /** One page of the documents without `_seq`, in the order of `_id` (the sweep of `watchStorageChanges()`). */
1468
+ private async rawDocuments(after: { readonly id: unknown } | undefined, limit: number): Promise<Document[]> {
1469
+ const db = await this.database();
1470
+ // The scan walks the index of _id from the last _id: `min` bounds the scan, in the order of the
1471
+ // BSON types. `min` includes that _id, so the page skips it. No index can find the documents
1472
+ // without a field (a partial index cannot express $exists: false): the scan reads each document.
1473
+ let cursor = db.collection(this.collectionName).find({ [SEQ]: { $exists: false } }).hint({ _id: 1 });
1474
+ if (after) cursor = cursor.min({ _id: after.id });
1475
+ const docs = await cursor.limit(after ? limit + 1 : limit).toArray();
1476
+ if (after && docs.length > 0 && sameBson(docs[0]!["_id"], after.id)) docs.shift();
1477
+ return docs.slice(0, limit);
1478
+ }
1479
+
1480
+ /** The stored documents of these stored `_id`s, for the adoptions that `watchStorageChanges()` does again. */
1481
+ private async storedDocuments(ids: readonly unknown[]): Promise<Document[]> {
1482
+ const db = await this.database();
1483
+ const filter: Document = { _id: { $in: [...ids] } };
1484
+ return db.collection(this.collectionName).find(filter).toArray();
1485
+ }
1486
+
1487
+ /**
1488
+ * The storage of the partition of a raw write and the id of its document, or undefined when
1489
+ * neither the documents of the event nor the stored `_id` tell them. The partition comes from
1490
+ * `form.partitionOf()` of the image before the change, else of the document after the write,
1491
+ * else of the stored `_id` (the order of `storageChangeOf()`). The document must not have a
1492
+ * partition field with another value.
1493
+ */
1494
+ private adoptionTarget(raw: RawStorageWrite): { storage: MongoDocumentStorage<T>; id: string } | undefined {
1495
+ const doc = raw.after ?? raw.before;
1496
+ if (!doc) return undefined;
1497
+ const places = new Set<string | undefined>();
1498
+ for (const source of [raw.before, raw.after, { _id: raw.storedId }]) if (isPlainObject(source)) places.add(this.form.partitionOf(source));
1499
+ for (const place of places) {
1500
+ if (place !== this.partition && (this.partition !== undefined || place === undefined)) continue;
1501
+ let storage: MongoDocumentStorage<T>;
1502
+ try {
1503
+ storage = place === this.partition ? this : this.forPartition(place as string);
1504
+ } catch {
1505
+ continue;
1506
+ }
1507
+ if (!storage.holds(doc)) continue;
1508
+ const fromId = this.form.idOf(raw.storedId, place);
1509
+ const key = raw.after?.[KEY] ?? raw.before?.[KEY];
1510
+ const id = typeof fromId === "string" ? fromId : typeof key === "string" ? key : undefined;
1511
+ if (id === undefined || id.includes(SEPARATOR)) continue;
1512
+ return { storage, id };
1513
+ }
1514
+ return undefined;
1515
+ }
1516
+
1517
+ /** True when each partition field of a document has the value of this partition, or is absent (a raw replace can drop it). */
1518
+ private holds(doc: Document): boolean {
1519
+ for (const [name, value] of this.partitionEntries) {
1520
+ if (!(name in doc)) continue;
1521
+ if (value === undefined || !isDeepStrictEqual(doc[name], value)) return false;
1522
+ }
1523
+ return true;
1524
+ }
1525
+
1526
+ /**
1527
+ * The storage fields and the partition fields that a raw write took from the document, for the
1528
+ * adoption to set again. No field of the item is in it.
1529
+ *
1530
+ * - The partition fields of this partition that the document does not have.
1531
+ * - `_key` (with `_kid`) of the image before the change. Without the image, `_key` only when
1532
+ * `form.idOf()` does not tell the id, with `_kid`: the item keeps its `_id`, as a document from
1533
+ * outside the storage.
1534
+ * - `_ord` of the image before the change, else `0`, the place of a document without `_ord`
1535
+ * (the rule of the old documents).
1536
+ * - The names of `_kpf` of the image before the change that the document has with the value of
1537
+ * the partition.
1538
+ */
1539
+ private restoredFields(raw: RawStorageWrite, after: Document, id: string): Document {
1540
+ const before = raw.before;
1541
+ const fields: Document = {};
1542
+ for (const [name, value] of this.partitionEntries) if (value !== undefined && !(name in after)) fields[name] = value;
1543
+ if (after[KEY] === undefined) {
1544
+ if (typeof before?.[KEY] === "string") {
1545
+ fields[KEY] = before[KEY];
1546
+ if (before[KEEP_ID] === true && after[KEEP_ID] === undefined) fields[KEEP_ID] = true;
1547
+ } else if (this.form.idOf(raw.storedId, this.partition) !== id) {
1548
+ fields[KEY] = id;
1549
+ if (after[KEEP_ID] === undefined) fields[KEEP_ID] = true;
1550
+ }
1551
+ }
1552
+ if (after[ORD] === undefined) fields[ORD] = typeof before?.[ORD] === "number" ? before[ORD] : 0;
1553
+ const kept = before?.[KEEP_PART];
1554
+ if (after[KEEP_PART] === undefined && Array.isArray(kept)) {
1555
+ const names = kept.filter((name): name is string => typeof name === "string" && this.partitionValues.has(name) && isDeepStrictEqual(after[name], this.partitionValues.get(name)));
1556
+ if (names.length > 0) fields[KEEP_PART] = names;
1557
+ }
1558
+ return fields;
1559
+ }
1560
+
1561
+ /** The partitions that the storage knows: the floor documents of the counters (of the first writes and of the prunes), and the storages of `forPartition()`. */
1562
+ private async knownPartitions(): Promise<Array<string | undefined>> {
1563
+ const db = await this.database();
1564
+ const prefix = `floor${SEPARATOR}${this.collectionName}${SEPARATOR}`;
1565
+ const floors = await db
1566
+ .collection<CounterDoc>(this.countersName)
1567
+ .find({ _id: { $regex: `^${escapeRegex(prefix)}` } } as Filter<CounterDoc>, { projection: { _id: 1 } })
1568
+ .limit(MAX_KNOWN_PARTITIONS)
1569
+ .toArray();
1570
+ const found = new Set<string | undefined>(this.partitions.keys());
1571
+ for (const floor of floors) {
1572
+ const rest = String(floor._id).slice(prefix.length);
1573
+ found.add(rest === "" ? undefined : rest);
1574
+ }
1575
+ return [...found];
1576
+ }
1577
+
1191
1578
  /**
1192
1579
  * The preconditions of a write (roadmap CLI-16), after a check of their ids. A storage without
1193
1580
  * `versionOf` throws a `TypeError` before the write starts.
@@ -12,8 +12,12 @@ export {
12
12
  TOMBSTONE_FIELD,
13
13
  DEFAULT_STORED_FORM,
14
14
  decodeStoredDocument,
15
+ versionOfStored,
15
16
  } from "./document-storage.js";
16
17
  export type { MongoDocumentStorageOptions, SelectorPrefilterOptions, StoredForm, DecodeStoredDocumentOptions, StoredDocumentEntry } from "./document-storage.js";
18
+ // The change hints of a storage collection from its change stream (roadmap CLI-77)
19
+ export { watchStorageChanges, storageChangeOf } from "./storage-hints.js";
20
+ export type { StorageChange, StorageChangeHint, StorageChangesWatch, StorageHintPublisher, WatchStorageChangesOptions } from "./storage-hints.js";
17
21
  export {
18
22
  BLOCKED_PIPELINE_OPERATORS,
19
23
  ALLOWED_PIPELINE_STAGES,
@@ -56,6 +56,9 @@ import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@
56
56
  import { DEFAULT_STORED_FORM, TOMBSTONE_FIELD, type StoredForm } from "./document-storage.js";
57
57
  import { scopePipeline } from "./pipeline-scope.js";
58
58
 
59
+ /** The fields of a storage that a row never keeps: its sequence number and its tombstone flag (roadmap CLI-77). */
60
+ const ROW_DROPPED_FIELDS = ["_seq", TOMBSTONE_FIELD] as const;
61
+
59
62
  /** The definition of a view, as a document of the views collection. */
60
63
  export interface MaterializedView {
61
64
  _id: string;
@@ -874,6 +877,10 @@ export class ViewMaterializer {
874
877
  // The fields of the materializer, for a row that comes from a target: not in the content
875
878
  const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
876
879
  if (_id === undefined || _id === null) throw new ViewRunError("each row of the result needs an _id");
880
+ // The sequence number and the tombstone flag of a MongoDocumentStorage, for a row that passes a
881
+ // source document through: a row is no write of a storage, so a watcher of the target must not
882
+ // read them (roadmap CLI-77). The other fields, for example _json and _key, are the content of the row.
883
+ for (const name of ROW_DROPPED_FIELDS) delete content[name];
877
884
  const stored = this.targetId(_id, partition, generation);
878
885
  const hash = createHash("sha1").update(canonical(content)).digest("hex");
879
886
  group.push({