@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
@@ -4,8 +4,20 @@
4
4
 
5
5
  export { MongoStorage } from "./mongo-storage.js";
6
6
  export type { MongoStorageOptions } from "./mongo-storage.js";
7
- export { MongoDocumentStorage, selectorPrefilter } from "./document-storage.js";
8
- export type { MongoDocumentStorageOptions } from "./document-storage.js";
7
+ // The stored form of a MongoDocumentStorage is an option (roadmap CLI-76)
8
+ export {
9
+ MongoDocumentStorage,
10
+ selectorPrefilter,
11
+ DOCUMENT_STORAGE_FIELDS,
12
+ TOMBSTONE_FIELD,
13
+ DEFAULT_STORED_FORM,
14
+ decodeStoredDocument,
15
+ versionOfStored,
16
+ } from "./document-storage.js";
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";
9
21
  export {
10
22
  BLOCKED_PIPELINE_OPERATORS,
11
23
  ALLOWED_PIPELINE_STAGES,
@@ -19,7 +31,7 @@ export {
19
31
  export type { PipelineLimits, PipelineScope } from "./pipeline-scope.js";
20
32
  // Materialized views driven by change streams (Graph's split plan, P4)
21
33
  export { ViewMaterializer, identityPartition, partitionTargetId, viewFilter, VIEW_GENERATION_FIELD, VIEW_HASH_FIELD } from "./materializer.js";
22
- export type { MaterializedView, MaterializedViewStatus, ViewMaterializerOptions, ViewPartitionOf, ViewPipelineScope, ViewTargetId } from "./materializer.js";
34
+ export type { MaterializedView, MaterializedViewStatus, ViewMaterializerOptions, ViewPartitionOf, ViewPipelineScope, ViewStorageSource, ViewTargetId } from "./materializer.js";
23
35
  // A read-only storage of a view for the collection procedures: one generation for each read (roadmap CLI-38)
24
36
  export { MongoViewStorage } from "./view-storage.js";
25
37
  export type { MongoViewStorageOptions } from "./view-storage.js";
@@ -38,8 +38,9 @@
38
38
  *
39
39
  * A change reaches only the views of its partition when its partition is known. A delete has no
40
40
  * document: its partition comes from the pre-image (a collection with
41
- * `changeStreamPreAndPostImages`), or from the id in a source of `storageSources`, through its
42
- * mapping. Else it reaches the views of each partition of its source. With pre-images, an update
41
+ * `changeStreamPreAndPostImages`), or, in a source of `storageSources`, from `partitionOf` of the
42
+ * stored form and the mapping of the source (roadmap CLI-76). Else it reaches the views of each
43
+ * partition of its source. With pre-images, an update
43
44
  * that moves a document to another partition also reaches the views of its old partition.
44
45
  *
45
46
  * Change streams and transactions need a replica set (MongoDB 6 or later). The target of a view is
@@ -52,9 +53,12 @@
52
53
  import { createHash } from "node:crypto";
53
54
  import { BSON, ObjectId, type ChangeStream, type ClientSession, type Collection, type Db, type Document } from "mongodb";
54
55
  import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client";
55
- import { SEPARATOR } from "./document-storage.js";
56
+ import { DEFAULT_STORED_FORM, TOMBSTONE_FIELD, type StoredForm } from "./document-storage.js";
56
57
  import { scopePipeline } from "./pipeline-scope.js";
57
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
+
58
62
  /** The definition of a view, as a document of the views collection. */
59
63
  export interface MaterializedView {
60
64
  _id: string;
@@ -142,13 +146,15 @@ export interface ViewMaterializerOptions {
142
146
  */
143
147
  passLease?: Duration | undefined;
144
148
  /**
145
- * The sources that are collections of a `MongoDocumentStorage`, each with the function that
146
- * gives the partition of the views from the partition of the storage. The stored id of a document
147
- * starts with its storage partition and U+001F. A delete without a pre-image then reaches only the
148
- * views of its partition. Give `identityPartition` when the two partitions are the same. Without a
149
- * function, or when it gives `undefined`, the delete reaches the views of each partition.
149
+ * The sources that are collections of a `MongoDocumentStorage`. For each one, a function that
150
+ * gives the partition of the views from the partition of the storage (the storage has the default
151
+ * form), or a `ViewStorageSource` with the stored form of the storage (`storage.form`) and that
152
+ * function. A change without a pre-image then reaches only the views of its partition: the
153
+ * `partitionOf` of the form reads the storage partition from the document of the change (for a
154
+ * delete, only `documentKey`). Give `identityPartition` when the two partitions are the same.
155
+ * When the form or the function gives `undefined`, the change reaches the views of each partition.
150
156
  */
151
- storageSources?: Readonly<Record<string, ViewPartitionOf>> | undefined;
157
+ storageSources?: Readonly<Record<string, ViewPartitionOf | ViewStorageSource>> | undefined;
152
158
  }
153
159
 
154
160
  /** The function that gives the stored `_id` of a row of a view. */
@@ -212,6 +218,14 @@ export type ViewPartitionOf = (storagePartition: string) => string | undefined;
212
218
  /** The partition of the views is the partition of the storage. */
213
219
  export const identityPartition: ViewPartitionOf = (storagePartition) => storagePartition;
214
220
 
221
+ /** A source of `storageSources` with its stored form (roadmap CLI-76). */
222
+ export interface ViewStorageSource {
223
+ /** The stored form of the storage, for example `storage.form`. Its `partitionOf` reads the storage partition of a document. */
224
+ form: Pick<StoredForm, "partitionOf">;
225
+ /** The partition of the views from the partition of the storage. */
226
+ viewPartitionOf: ViewPartitionOf;
227
+ }
228
+
215
229
  /** A view that the materializer knows. */
216
230
  interface RunningView {
217
231
  view: MaterializedView;
@@ -278,7 +292,7 @@ export class ViewMaterializer {
278
292
  private readonly maxTransactionRows: number;
279
293
  private readonly graceMs: number;
280
294
  private readonly leaseMs: number;
281
- private readonly storageSources: ReadonlyMap<string, ViewPartitionOf>;
295
+ private readonly storageSources: ReadonlyMap<string, ViewStorageSource>;
282
296
  /** False after `stopIntake()`: a change starts no pass. */
283
297
  private intake = false;
284
298
  /** The work of each partition, one at a time: the passes and the rebalances. */
@@ -300,7 +314,15 @@ export class ViewMaterializer {
300
314
  this.maxTransactionRows = options.maxTransactionRows ?? 10_000;
301
315
  this.graceMs = optionalMs(options.generationGrace, "generationGrace") ?? 60_000;
302
316
  this.leaseMs = Math.round(optionalMs(options.passLease, "passLease") ?? 60_000);
303
- this.storageSources = new Map(Object.entries(options.storageSources ?? {}));
317
+ this.storageSources = new Map(
318
+ Object.entries(options.storageSources ?? {}).map(([source, entry]): [string, ViewStorageSource] => {
319
+ const normal = typeof entry === "function" ? { form: DEFAULT_STORED_FORM, viewPartitionOf: entry } : entry;
320
+ if (typeof normal?.form?.partitionOf !== "function" || typeof normal.viewPartitionOf !== "function") {
321
+ throw new TypeError(`The storage source ${source} needs a function, or a form with partitionOf and a viewPartitionOf`);
322
+ }
323
+ return [source, normal];
324
+ })
325
+ );
304
326
  }
305
327
 
306
328
  private log(level: "info" | "warn" | "error", message: string): void {
@@ -310,11 +332,13 @@ export class ViewMaterializer {
310
332
 
311
333
  /**
312
334
  * The guard of a pipeline of a view. A target of another view counts only in its current
313
- * generation. A source document has no generation.
335
+ * generation. A source document has no generation. A tombstone of a `MongoDocumentStorage` (a
336
+ * deleted item) is not a row of a source (roadmap CLI-79).
314
337
  */
315
338
  private scoped(pipeline: Document[], partition: string, view: MaterializedView, generations: readonly ObjectId[]): Document[] {
316
339
  const match = {
317
340
  [this.partitionField]: partition,
341
+ [TOMBSTONE_FIELD]: { $ne: true },
318
342
  $or: [{ [VIEW_GENERATION_FIELD]: { $exists: false } }, { [VIEW_GENERATION_FIELD]: { $in: [...generations] } }],
319
343
  };
320
344
  if (this.options.scope) return this.options.scope(pipeline, partition, view, match);
@@ -570,21 +594,32 @@ export class ViewMaterializer {
570
594
 
571
595
  /**
572
596
  * True for a change without a pre-image (a delete, a replace, an update), in a source of
573
- * `storageSources`, whose id names a storage partition that maps to another partition of the
574
- * views. A document that is in the partition after the change, an id without the separator, a
575
- * mapping that gives `undefined` and a mapping that fails do not tell another partition: false.
597
+ * `storageSources`, whose document names a storage partition that maps to another partition of
598
+ * the views. The document is `fullDocument`, else `fullDocumentBeforeChange`, else `documentKey`,
599
+ * and `partitionOf` of the form reads its storage partition. A document that is in the partition
600
+ * after the change, a document that does not tell its partition, a key (`documentKey`) that tells
601
+ * another storage partition than the document after the change (a move), a mapping that gives
602
+ * `undefined` and a function that fails do not tell another partition: false.
576
603
  */
577
604
  private isOtherPartition(change: Document, source: string, partition: string): boolean {
578
- const viewPartitionOf = this.storageSources.get(source);
579
- if (!viewPartitionOf || change["fullDocumentBeforeChange"]) return false;
605
+ const storageSource = this.storageSources.get(source);
606
+ if (!storageSource || change["fullDocumentBeforeChange"]) return false;
580
607
  if (!["delete", "replace", "update"].includes(change["operationType"] as string)) return false;
581
- if ((change["fullDocument"] as Document | null | undefined)?.[this.partitionField] === partition) return false;
582
- const id = (change["documentKey"] as Document | undefined)?.["_id"];
583
- const at = typeof id === "string" ? id.indexOf(SEPARATOR) : -1;
584
- if (at <= 0) return false;
608
+ const after = change["fullDocument"] as Document | null | undefined;
609
+ if (after?.[this.partitionField] === partition) return false;
610
+ const key = change["documentKey"] as Document | null | undefined;
611
+ const doc = (after ?? (change["fullDocumentBeforeChange"] as Document | null | undefined) ?? key) as Document | null | undefined;
612
+ if (!doc) return false;
585
613
  let viewPartition: string | undefined;
586
614
  try {
587
- viewPartition = viewPartitionOf((id as string).slice(0, at));
615
+ const storagePartition = storageSource.form.partitionOf(doc);
616
+ if (typeof storagePartition !== "string") return false;
617
+ // A document whose key tells another partition than the document after the change can have moved out of this partition
618
+ if (after && key) {
619
+ const before = storageSource.form.partitionOf(key);
620
+ if (before !== undefined && before !== storagePartition) return false;
621
+ }
622
+ viewPartition = storageSource.viewPartitionOf(storagePartition);
588
623
  } catch {
589
624
  return false;
590
625
  }
@@ -842,6 +877,10 @@ export class ViewMaterializer {
842
877
  // The fields of the materializer, for a row that comes from a target: not in the content
843
878
  const { _id, [this.partitionField]: _partition, [VIEW_GENERATION_FIELD]: _generation, [VIEW_HASH_FIELD]: _hash, ...content } = row;
844
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];
845
884
  const stored = this.targetId(_id, partition, generation);
846
885
  const hash = createHash("sha1").update(canonical(content)).digest("hex");
847
886
  group.push({
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The writes of this process (roadmap CLI-77). Each write of a `MongoDocumentStorage` takes
3
+ * numbers of the sequence of its collection (`_seq`). While a watcher of the collection
4
+ * (`watchStorageChanges`) tracks the collection, the storage notes here each number that it takes,
5
+ * and the stored `_id` of each tombstone that it prunes. The watcher then knows the change events
6
+ * of the writes of this process.
7
+ *
8
+ * - The numbers: the collection procedures of this process publish the events of these writes
9
+ * already, so a watcher with `ownWrites: "skip"` skips them.
10
+ * - The pruned tombstones: the delete of a pruned tombstone deletes no item. Without the image
11
+ * before the change, the event of the delete has only the stored `_id`, so the watcher reads it
12
+ * here, and skips the event.
13
+ * - Each watcher has its own tracker. A tracker takes each noted entry once: the event of the
14
+ * write takes it. A later event with the same `_seq` (for example a raw `$set` that keeps the
15
+ * field) is not of this process, and the watcher reads it as a raw write.
16
+ * - The storage notes entries only while a tracker of the collection is open, so a process without
17
+ * a watcher keeps nothing.
18
+ * - The trackers of a collection are by the `MongoClient` of the database and the names of the
19
+ * database and of the collection: two `Db` objects of one database share them.
20
+ * - An entry without an event (for example of an aborted transaction) goes after
21
+ * `OWN_WRITE_TTL_MS` on the clock of the tracker, and a tracker keeps at most `MAX_OWN_WRITES`
22
+ * entries of each kind: the oldest go first. An entry that goes too early makes a second hint at
23
+ * most, never a lost hint.
24
+ * - The numbers of an adoption of a raw write (D39) are never noted: the watcher publishes the
25
+ * hint of the adoption.
26
+ */
27
+ import { BSON, type Db } from "mongodb";
28
+ import type { Clock } from "@procedures/client";
29
+
30
+ /** The longest time that a noted entry waits for its change event: 5 minutes. */
31
+ const OWN_WRITE_TTL_MS = 5 * 60_000;
32
+
33
+ /** The most noted entries of each kind of one tracker. */
34
+ const MAX_OWN_WRITES = 100_000;
35
+
36
+ /** The noted writes of one watcher of one collection. */
37
+ export interface OwnNumbers {
38
+ /** True when a write of this process noted the number and no event took it yet. The number is then taken. */
39
+ take(seq: number): boolean;
40
+ /** True when this process pruned the tombstone of the stored `_id` and no event took it yet. The entry is then taken. */
41
+ takePruned(storedId: unknown): boolean;
42
+ /** This method ends the tracking: the storage notes no more entries for it. */
43
+ close(): void;
44
+ }
45
+
46
+ /** The options of `trackOwnNumbers()`. */
47
+ export interface TrackOwnNumbersOptions {
48
+ /** False: the tracker keeps only the pruned tombstones, not the numbers (default true). */
49
+ readonly numbers?: boolean | undefined;
50
+ /** The clock of the time limit of the entries (default `Date.now`). */
51
+ readonly clock?: Pick<Clock, "now"> | undefined;
52
+ }
53
+
54
+ interface Tracker {
55
+ readonly numbers: Map<number, number> | undefined;
56
+ readonly pruned: Map<string, number>;
57
+ readonly now: () => number;
58
+ }
59
+
60
+ /** The trackers of each collection: by the client (else the `Db` object), then by the names of the database and the collection. */
61
+ const TRACKERS = new WeakMap<object, Map<string, Set<Tracker>>>();
62
+
63
+ function ownerOf(db: Db): object {
64
+ const client = (db as { client?: unknown }).client;
65
+ return typeof client === "object" && client !== null ? client : db;
66
+ }
67
+
68
+ function nameOf(db: Db, collection: string): string {
69
+ return `${db.databaseName}\u0000${collection}`;
70
+ }
71
+
72
+ function trackersOf(db: Db, collection: string): Set<Tracker> | undefined {
73
+ return TRACKERS.get(ownerOf(db))?.get(nameOf(db, collection));
74
+ }
75
+
76
+ /** The key of a stored `_id` in a tracker: its extended JSON, so an `ObjectId` and a text never meet. */
77
+ function idKey(storedId: unknown): string {
78
+ return BSON.EJSON.stringify({ v: storedId as never }, { relaxed: false });
79
+ }
80
+
81
+ /** This function notes some keys in a map with a time limit, and drops the old ones. */
82
+ function noteAll<K>(map: Map<K, number>, keys: Iterable<K>, now: number): void {
83
+ for (const key of keys) {
84
+ // A key noted again (an aborted transaction gives its numbers again) moves to the end
85
+ map.delete(key);
86
+ map.set(key, now + OWN_WRITE_TTL_MS);
87
+ }
88
+ // The map keeps the order of the notes: the oldest keys are first
89
+ for (const [key, until] of map) {
90
+ if (until > now && map.size <= MAX_OWN_WRITES) break;
91
+ map.delete(key);
92
+ }
93
+ }
94
+
95
+ /** This function starts a tracker of the writes of this process in a collection. */
96
+ export function trackOwnNumbers(db: Db, collection: string, options: TrackOwnNumbersOptions = {}): OwnNumbers {
97
+ const owner = ownerOf(db);
98
+ const name = nameOf(db, collection);
99
+ const clock = options.clock;
100
+ const tracker: Tracker = {
101
+ numbers: options.numbers === false ? undefined : new Map(),
102
+ pruned: new Map(),
103
+ now: clock ? () => clock.now() : Date.now,
104
+ };
105
+ let byName = TRACKERS.get(owner);
106
+ if (!byName) {
107
+ byName = new Map();
108
+ TRACKERS.set(owner, byName);
109
+ }
110
+ let trackers = byName.get(name);
111
+ if (!trackers) {
112
+ trackers = new Set();
113
+ byName.set(name, trackers);
114
+ }
115
+ trackers.add(tracker);
116
+ const live = (map: Map<unknown, number>, key: unknown): boolean => {
117
+ const until = map.get(key);
118
+ if (until === undefined) return false;
119
+ map.delete(key);
120
+ return until > tracker.now();
121
+ };
122
+ return {
123
+ take(seq: number): boolean {
124
+ return tracker.numbers !== undefined && live(tracker.numbers as Map<unknown, number>, seq);
125
+ },
126
+ takePruned(storedId: unknown): boolean {
127
+ return live(tracker.pruned as Map<unknown, number>, idKey(storedId));
128
+ },
129
+ close(): void {
130
+ const set = TRACKERS.get(owner)?.get(name);
131
+ set?.delete(tracker);
132
+ if (set?.size === 0) TRACKERS.get(owner)?.delete(name);
133
+ tracker.numbers?.clear();
134
+ tracker.pruned.clear();
135
+ },
136
+ };
137
+ }
138
+
139
+ /** This function notes the numbers `first` to `first + count - 1` of a write of this process, for each open tracker of the collection. */
140
+ export function noteOwnNumbers(db: Db, collection: string, first: number, count: number): void {
141
+ const trackers = trackersOf(db, collection);
142
+ if (!trackers) return;
143
+ for (const tracker of trackers) {
144
+ if (!tracker.numbers) continue;
145
+ const numbers: number[] = [];
146
+ for (let seq = first; seq < first + count; seq++) numbers.push(seq);
147
+ noteAll(tracker.numbers, numbers, tracker.now());
148
+ }
149
+ }
150
+
151
+ /** This function notes the stored `_id` of each tombstone that this process prunes, for each open tracker of the collection. Call it before the delete. */
152
+ export function notePrunedIds(db: Db, collection: string, storedIds: readonly unknown[]): void {
153
+ const trackers = trackersOf(db, collection);
154
+ if (!trackers) return;
155
+ const keys = storedIds.map(idKey);
156
+ for (const tracker of trackers) noteAll(tracker.pruned, keys, tracker.now());
157
+ }