@procedures/mongo 0.9.0 → 0.9.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.
@@ -0,0 +1,694 @@
1
+ /**
2
+ * `MongoDocumentStorage`: a `CollectionStorage` over MongoDB with flat documents, a change log,
3
+ * versions, queries in the database and partitions (roadmap CLI-24). `MongoStorage` keeps its
4
+ * `{ _id, data }` documents.
5
+ *
6
+ * The documents:
7
+ * - An item that is a plain object with safe keys is stored flat: its fields beside the fields of
8
+ * the storage. Another item (null, a primitive, an array, or an object with a key of the storage,
9
+ * a key that starts with `$` or a key with a dot) is stored as JSON text in `_json`. Both ways
10
+ * give the item back as it was.
11
+ * - An item whose `_id` is its id keeps it: the storage gives it back.
12
+ * - The fields of the storage: `_key` (the id), `_part` (the partition), `_seq` (the sequence
13
+ * number of the last write), `_ord` (the number of the first write: the order of the entries, as
14
+ * a `Map` keeps it) and `_tomb` (a deleted item).
15
+ * - With a partition, the stored `_id` is `<partition>` + U+001F + `<id>`. An id cannot have the
16
+ * character U+001F. Each partition reads and writes only its documents: each write filter has the
17
+ * partition too, so a write of a stored `_id` of another partition fails and changes nothing.
18
+ *
19
+ * The change log:
20
+ * - Each write gets the next number of the sequence of the collection. `_seq` is also the version
21
+ * of the item (`versionOf`).
22
+ * - A delete leaves a tombstone, so `getChangesSince` gives the exact deletes. The storage keeps at
23
+ * most `maxTombstones` tombstones in each partition. A version before the oldest dropped
24
+ * tombstone gives a full result.
25
+ * - A version names the epoch of the sequence. A version of another epoch gives a full result.
26
+ * - The log stays exact when several processes write. On a replica set (or a sharded cluster), a
27
+ * write takes its numbers and writes in one transaction, so the numbers become visible in their
28
+ * order. On a single server, a watermark does it: each write keeps its numbers pending in the
29
+ * counter document until it ends, and a version stops below the lowest pending number. A
30
+ * pending number of a writer that stopped expires after `writeLease`. A write that ends after
31
+ * its lease moves its documents to new numbers, so a reader that passed the old numbers gets
32
+ * it. One case stays on a single server: a process that stops between such a late write and the
33
+ * move of its documents. Then a reader that passed the old numbers does not get that change.
34
+ *
35
+ * The queries: `query()` sends the conditions that MongoDB tests in the same way (the equality of
36
+ * a string, a number or a boolean, and `$in` of them) as a filter. `queryEntries()` of the core
37
+ * then gives the exact result, so a query always gives the result of the reference.
38
+ *
39
+ * The aggregations: `aggregate()` runs a read-only pipeline on the items of the partition, with
40
+ * the guard of `scopePipeline()` and the limits of time and size.
41
+ */
42
+ import { randomUUID } from "node:crypto";
43
+ import { applyPatch, queryEntries, } from "@procedures/collections";
44
+ import { optionalMs, toMs } from "@procedures/client";
45
+ import { ensureConnection } from "../connection.js";
46
+ import { boundPipeline, cutResults, DEFAULT_PIPELINE_LIMITS, scopePipeline } from "./pipeline-scope.js";
47
+ /** The fields of the storage in a stored document. */
48
+ const KEY = "_key";
49
+ const PART = "_part";
50
+ const SEQ = "_seq";
51
+ /** The number of the first write of an item since its last delete: the order of the entries. */
52
+ const ORD = "_ord";
53
+ const TOMB = "_tomb";
54
+ const JSON_FIELD = "_json";
55
+ /** True in a stored document whose item had `_id` equal to its id. */
56
+ const KEEP_ID = "_kid";
57
+ const STORAGE_FIELDS = new Set(["_id", KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID]);
58
+ /** The separator of the partition and the id in a stored `_id`. */
59
+ const SEPARATOR = "\u001f";
60
+ /** True when a database is on a replica set or a sharded cluster: it has transactions. */
61
+ const TRANSACTIONAL = new WeakMap();
62
+ function hasTransactions(db) {
63
+ let known = TRANSACTIONAL.get(db);
64
+ if (!known) {
65
+ known = db
66
+ .admin()
67
+ .command({ hello: 1 })
68
+ .then((hello) => typeof hello["setName"] === "string" || hello["msg"] === "isdbgrid")
69
+ .catch(() => false);
70
+ TRANSACTIONAL.set(db, known);
71
+ }
72
+ return known;
73
+ }
74
+ /** The most moves of a late write to new numbers. */
75
+ const MAX_MOVES = 10;
76
+ /** The writes and the change reads of each collection of each database, one at a time. */
77
+ const QUEUES = new WeakMap();
78
+ function enqueue(db, collection, task) {
79
+ let queues = QUEUES.get(db);
80
+ if (!queues)
81
+ QUEUES.set(db, (queues = new Map()));
82
+ const before = queues.get(collection) ?? Promise.resolve();
83
+ const run = before.then(task, task);
84
+ const settled = run.then(() => undefined, () => undefined);
85
+ queues.set(collection, settled);
86
+ void settled.then(() => {
87
+ if (queues.get(collection) === settled)
88
+ queues.delete(collection);
89
+ });
90
+ return run;
91
+ }
92
+ /** True for a plain object whose keys a flat document can keep, at each depth. */
93
+ function isFlatSafe(value, top) {
94
+ if (Array.isArray(value))
95
+ return value.every((element) => isFlatSafe(element, false));
96
+ if (typeof value !== "object" || value === null)
97
+ return true;
98
+ const proto = Object.getPrototypeOf(value);
99
+ if (proto !== Object.prototype && proto !== null)
100
+ return value instanceof Date;
101
+ for (const [key, field] of Object.entries(value)) {
102
+ if (key.startsWith("$") || key.includes(".") || key === "__proto__")
103
+ return false;
104
+ if (top && STORAGE_FIELDS.has(key) && key !== "_id")
105
+ return false;
106
+ if (!isFlatSafe(field, false))
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ /** True for a plain object. */
112
+ function isPlainObject(value) {
113
+ if (typeof value !== "object" || value === null || Array.isArray(value))
114
+ return false;
115
+ const proto = Object.getPrototypeOf(value);
116
+ return proto === Object.prototype || proto === null;
117
+ }
118
+ /** This function sets an own field (a key `__proto__` does not change the prototype). */
119
+ function put(target, key, value) {
120
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
121
+ }
122
+ /** The value of a scalar condition that MongoDB tests in the same way as the core. */
123
+ function isScalar(value) {
124
+ return typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
125
+ }
126
+ /** The field of a stored document for a path of a selector, or undefined when the filter cannot test it. */
127
+ function storedPath(path) {
128
+ if (path === "_id")
129
+ return KEY;
130
+ const first = path.split(".")[0];
131
+ if (first === "_id" || STORAGE_FIELDS.has(first))
132
+ return undefined;
133
+ return path;
134
+ }
135
+ /** The filter of one condition, or undefined when the filter cannot test it in the same way. */
136
+ function conditionFilter(path, condition) {
137
+ const field = storedPath(path);
138
+ if (field === undefined)
139
+ return undefined;
140
+ if (isScalar(condition))
141
+ return { [field]: condition };
142
+ if (!isPlainObject(condition))
143
+ return undefined;
144
+ const operators = {};
145
+ const eq = condition["$eq"];
146
+ if (isScalar(eq))
147
+ operators["$eq"] = eq;
148
+ const among = condition["$in"];
149
+ if (Array.isArray(among) && among.length > 0 && among.every(isScalar))
150
+ operators["$in"] = among;
151
+ return Object.keys(operators).length > 0 ? { [field]: operators } : undefined;
152
+ }
153
+ /**
154
+ * This function gives a filter that each item of the selector passes (a filter can pass more),
155
+ * or undefined for no condition. Only conditions that MongoDB tests in the same way go in it.
156
+ */
157
+ export function selectorPrefilter(selector) {
158
+ const parts = [];
159
+ for (const [key, condition] of Object.entries(selector)) {
160
+ if (condition === undefined)
161
+ continue;
162
+ if (key === "$and" && Array.isArray(condition)) {
163
+ for (const branch of condition) {
164
+ const filter = isPlainObject(branch) ? selectorPrefilter(branch) : undefined;
165
+ if (filter)
166
+ parts.push(filter);
167
+ }
168
+ }
169
+ else if (key === "$or" && Array.isArray(condition) && condition.length > 0) {
170
+ const branches = condition.map((branch) => (isPlainObject(branch) ? selectorPrefilter(branch) : undefined));
171
+ // A branch without a condition passes each item: then the $or tests nothing
172
+ if (branches.every((branch) => branch !== undefined))
173
+ parts.push({ $or: branches });
174
+ }
175
+ else if (!key.startsWith("$")) {
176
+ const filter = conditionFilter(key, condition);
177
+ if (filter)
178
+ parts.push(filter);
179
+ }
180
+ }
181
+ if (parts.length === 0)
182
+ return undefined;
183
+ return parts.length === 1 ? parts[0] : { $and: parts };
184
+ }
185
+ /**
186
+ * A `CollectionStorage` over MongoDB with flat documents, a change log, versions, queries in the
187
+ * database and partitions (roadmap CLI-24).
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * const notes = new MongoDocumentStorage<Note>({ collection: "notes" });
192
+ * server.usePartitions("notes", { storageOf: (partition) => notes.forPartition(partition), resolve });
193
+ * ```
194
+ */
195
+ export class MongoDocumentStorage {
196
+ options;
197
+ collectionName;
198
+ partition;
199
+ maxTombstones;
200
+ countersName;
201
+ writeLeaseMs;
202
+ /** The storages of the partitions, for `forPartition`: the same storage for the same partition. */
203
+ partitions;
204
+ indexes;
205
+ closed = false;
206
+ constructor(options, partitions) {
207
+ if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
208
+ throw new Error("A partition must be a text without the character U+001F");
209
+ }
210
+ this.options = options;
211
+ this.collectionName = options.collection;
212
+ this.partition = options.partition;
213
+ this.maxTombstones = options.maxTombstones ?? 10_000;
214
+ if (!Number.isSafeInteger(this.maxTombstones) || this.maxTombstones < 0)
215
+ throw new Error("maxTombstones must be a whole number of 0 or more");
216
+ this.countersName = options.counters ?? "__storage_counters";
217
+ this.writeLeaseMs = Math.round(optionalMs(options.writeLease, "writeLease") ?? 60_000);
218
+ if (!Number.isSafeInteger(this.writeLeaseMs) || this.writeLeaseMs < 1)
219
+ throw new Error("writeLease must be 1 ms or more");
220
+ this.partitions = partitions ?? new Map();
221
+ }
222
+ /** The storage of a partition of the same collection. It gives the same storage for the same partition. */
223
+ forPartition(partition) {
224
+ let storage = this.partitions.get(partition);
225
+ if (!storage) {
226
+ storage = new MongoDocumentStorage({ ...this.options, partition }, this.partitions);
227
+ this.partitions.set(partition, storage);
228
+ }
229
+ return storage;
230
+ }
231
+ // ===========================================================================
232
+ // The database
233
+ // ===========================================================================
234
+ async database() {
235
+ this.checkOpen();
236
+ return this.options.db ? await this.options.db() : (await ensureConnection()).getDb();
237
+ }
238
+ async collection() {
239
+ const db = await this.database();
240
+ const items = db.collection(this.collectionName);
241
+ this.indexes ??= items
242
+ .createIndexes([{ key: { [PART]: 1, [SEQ]: 1 } }, { key: { [PART]: 1, [ORD]: 1 } }, { key: { [PART]: 1, [TOMB]: 1 } }])
243
+ .then(() => undefined)
244
+ .catch((error) => {
245
+ this.indexes = undefined;
246
+ throw error;
247
+ });
248
+ await this.indexes;
249
+ return { db, items };
250
+ }
251
+ /** The filter of the documents of the partition. */
252
+ partitionFilter() {
253
+ return this.partition === undefined ? { [PART]: { $exists: false } } : { [PART]: this.partition };
254
+ }
255
+ /** The filter of the items (not the tombstones) of the partition. */
256
+ liveFilter(extra) {
257
+ return { ...this.partitionFilter(), [TOMB]: { $ne: true }, ...extra };
258
+ }
259
+ /** The stored `_id` of an id. An id with the separator could name an item of a partition: it is refused. */
260
+ storedId(id) {
261
+ if (typeof id !== "string" || id.includes(SEPARATOR))
262
+ throw new Error("An id must be a text without the character U+001F");
263
+ return this.partition === undefined ? id : `${this.partition}${SEPARATOR}${id}`;
264
+ }
265
+ /** The filter of a write of one stored document: its `_id` and the partition. */
266
+ writeFilter(id) {
267
+ return { _id: this.storedId(id), ...this.partitionFilter() };
268
+ }
269
+ /** The stored document of an item, with the number of this write and the number of its place. */
270
+ encode(id, value, seq, ord) {
271
+ const doc = { _id: this.storedId(id), [KEY]: id, [SEQ]: seq, [ORD]: ord };
272
+ if (this.partition !== undefined)
273
+ doc[PART] = this.partition;
274
+ const keepsId = isPlainObject(value) && value["_id"] === id;
275
+ if (isPlainObject(value) && isFlatSafe(value, true) && (!("_id" in value) || keepsId)) {
276
+ if (keepsId)
277
+ doc[KEEP_ID] = true;
278
+ for (const [key, field] of Object.entries(value))
279
+ if (key !== "_id")
280
+ put(doc, key, field);
281
+ }
282
+ else {
283
+ doc[JSON_FIELD] = JSON.stringify(value);
284
+ }
285
+ return doc;
286
+ }
287
+ /** The id and the item of a stored document. */
288
+ decode(doc) {
289
+ const id = String(doc[KEY]);
290
+ if (typeof doc[JSON_FIELD] === "string")
291
+ return [id, JSON.parse(doc[JSON_FIELD])];
292
+ const value = {};
293
+ if (doc[KEEP_ID] === true)
294
+ value["_id"] = id;
295
+ for (const [key, field] of Object.entries(doc))
296
+ if (!STORAGE_FIELDS.has(key))
297
+ put(value, key, field);
298
+ return [id, value];
299
+ }
300
+ /**
301
+ * The next `count` numbers of the sequence of the collection. They stay pending in the counter
302
+ * document until `release()`, so the watermark of the readers stays below them.
303
+ */
304
+ async allocate(db, count) {
305
+ const counter = (await db.collection(this.countersName).findOneAndUpdate({ _id: this.collectionName }, [
306
+ { $set: { seq: { $add: [{ $ifNull: ["$seq", 0] }, count] }, epoch: { $ifNull: ["$epoch", randomUUID()] } } },
307
+ {
308
+ $set: {
309
+ pending: {
310
+ $concatArrays: [{ $ifNull: ["$pending", []] }, [{ from: { $subtract: ["$seq", count - 1] }, to: "$seq", at: "$$NOW" }]],
311
+ },
312
+ },
313
+ },
314
+ ], { upsert: true, returnDocument: "after" }));
315
+ if (!counter)
316
+ throw new Error(`The sequence of ${this.collectionName} is not available`);
317
+ return { first: counter.seq - count + 1, epoch: counter.epoch };
318
+ }
319
+ /**
320
+ * This method ends the pending numbers of a write, and drops the pending numbers whose lease
321
+ * ended. In the same atomic step, the server tells if the numbers were still in their lease: the
322
+ * time since `at` only grows, so a range in its lease at the end was in its lease for the whole
323
+ * write, and no reader passed its numbers.
324
+ */
325
+ async release(db, first) {
326
+ const live = { $gt: ["$$this.at", { $subtract: ["$$NOW", this.writeLeaseMs] }] };
327
+ const after = (await db.collection(this.countersName).findOneAndUpdate({ _id: this.collectionName }, [
328
+ {
329
+ $set: {
330
+ lastRelease: {
331
+ from: first,
332
+ inLease: {
333
+ $anyElementTrue: [
334
+ { $map: { input: { $filter: { input: { $ifNull: ["$pending", []] }, cond: { $eq: ["$$this.from", first] } } }, in: live } },
335
+ ],
336
+ },
337
+ },
338
+ pending: { $filter: { input: { $ifNull: ["$pending", []] }, cond: { $and: [{ $ne: ["$$this.from", first] }, live] } } },
339
+ },
340
+ },
341
+ ], { returnDocument: "after", projection: { lastRelease: 1 } }));
342
+ return after?.lastRelease?.from === first && after.lastRelease.inLease === true;
343
+ }
344
+ /** This method moves the documents of a late write from its numbers to new numbers. */
345
+ async move(collection, first, count, next) {
346
+ const shift = next - first;
347
+ await collection.updateMany({ ...this.partitionFilter(), [SEQ]: { $gte: first, $lte: first + count - 1 } }, [
348
+ {
349
+ $set: {
350
+ // A new item keeps its place at its first number: the place moves with it
351
+ [ORD]: { $cond: [{ $eq: [`$${ORD}`, `$${SEQ}`] }, { $add: [`$${SEQ}`, shift] }, `$${ORD}`] },
352
+ [SEQ]: { $add: [`$${SEQ}`, shift] },
353
+ },
354
+ },
355
+ ]);
356
+ }
357
+ /** The client for the transactions of the writes, or undefined for the watermark. */
358
+ async transactionClient(db) {
359
+ if (this.options.transactions === "never")
360
+ return undefined;
361
+ const client = this.options.client ? await this.options.client() : this.options.db ? undefined : (await ensureConnection()).getClient();
362
+ return client && (await hasTransactions(db)) ? client : undefined;
363
+ }
364
+ /**
365
+ * This method takes numbers and writes with them. On a replica set, the two are one
366
+ * transaction. Else the numbers stay pending until the write ends, and a late write moves its
367
+ * documents to new numbers.
368
+ */
369
+ async withNumbers(db, collection, count, write) {
370
+ const client = await this.transactionClient(db);
371
+ if (client) {
372
+ const session = client.startSession();
373
+ try {
374
+ return await session.withTransaction(async () => {
375
+ const counter = (await db.collection(this.countersName).findOneAndUpdate({ _id: this.collectionName }, { $inc: { seq: count }, $setOnInsert: { epoch: randomUUID() } }, { upsert: true, returnDocument: "after", session }));
376
+ if (!counter)
377
+ throw new Error(`The sequence of ${this.collectionName} is not available`);
378
+ return write(counter.seq - count + 1, session);
379
+ });
380
+ }
381
+ finally {
382
+ await session.endSession();
383
+ }
384
+ }
385
+ let { first } = await this.allocate(db, count);
386
+ let result;
387
+ try {
388
+ result = await write(first);
389
+ }
390
+ catch (error) {
391
+ await this.release(db, first).catch(() => undefined);
392
+ throw error;
393
+ }
394
+ for (let moves = 0; !(await this.release(db, first)); moves++) {
395
+ if (moves >= MAX_MOVES)
396
+ throw new Error(`A write of ${this.collectionName} stayed late after ${MAX_MOVES} moves to new numbers`);
397
+ // Late: a reader can have passed these numbers. The write takes new ones.
398
+ const next = (await this.allocate(db, count)).first;
399
+ await this.move(collection, first, count, next);
400
+ first = next;
401
+ }
402
+ return result;
403
+ }
404
+ /**
405
+ * The watermark of the collection: the highest number below each pending number of a write whose
406
+ * lease has not ended, and the epoch. Each number up to it is written or abandoned.
407
+ */
408
+ async watermark(db) {
409
+ const counters = db.collection(this.countersName);
410
+ await counters.updateOne({ _id: this.collectionName }, { $setOnInsert: { seq: 0, epoch: randomUUID() } }, { upsert: true });
411
+ const [mark] = await counters
412
+ .aggregate([
413
+ { $match: { _id: this.collectionName } },
414
+ {
415
+ $project: {
416
+ seq: 1,
417
+ epoch: 1,
418
+ low: {
419
+ $min: {
420
+ $map: {
421
+ input: {
422
+ $filter: { input: { $ifNull: ["$pending", []] }, cond: { $gt: ["$$this.at", { $subtract: ["$$NOW", this.writeLeaseMs] }] } },
423
+ },
424
+ in: "$$this.from",
425
+ },
426
+ },
427
+ },
428
+ },
429
+ },
430
+ ])
431
+ .toArray();
432
+ if (!mark)
433
+ throw new Error(`The sequence of ${this.collectionName} is not available`);
434
+ const seq = typeof mark.low === "number" ? Math.min(mark.seq, mark.low - 1) : mark.seq;
435
+ return { seq, epoch: mark.epoch };
436
+ }
437
+ /** The id of the document of the oldest kept change of the partition. */
438
+ floorId() {
439
+ return `floor${SEPARATOR}${this.collectionName}${SEPARATOR}${this.partition ?? ""}`;
440
+ }
441
+ /** This method drops the oldest tombstones of the partition over the limit, and keeps the floor of the log. */
442
+ async pruneTombstones(db, items) {
443
+ const count = await items.countDocuments({ ...this.partitionFilter(), [TOMB]: true });
444
+ const excess = count - this.maxTombstones;
445
+ if (excess <= 0)
446
+ return;
447
+ const oldest = await items
448
+ .find({ ...this.partitionFilter(), [TOMB]: true }, { projection: { _id: 1, [SEQ]: 1 } })
449
+ .sort({ [SEQ]: 1 })
450
+ .limit(excess)
451
+ .toArray();
452
+ if (oldest.length === 0)
453
+ return;
454
+ await items.deleteMany({ _id: { $in: oldest.map((doc) => doc._id) } });
455
+ const floor = Math.max(...oldest.map((doc) => Number(doc[SEQ])));
456
+ await db.collection(this.countersName).updateOne({ _id: this.floorId() }, { $max: { floor } }, { upsert: true });
457
+ }
458
+ /** The items of the partition, in the order of the entries. */
459
+ async liveDocs(extra) {
460
+ const { items } = await this.collection();
461
+ return items.find(this.liveFilter(extra)).sort({ [ORD]: 1 }).toArray();
462
+ }
463
+ /** The places (`_ord`) of the items of some ids that the partition has. */
464
+ async placesOf(collection, ids) {
465
+ const docs = await collection
466
+ .find(this.liveFilter({ _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } }), { projection: { [KEY]: 1, [ORD]: 1 } })
467
+ .toArray();
468
+ return new Map(docs.map((doc) => [String(doc[KEY]), Number(doc[ORD])]));
469
+ }
470
+ // ===========================================================================
471
+ // Reads
472
+ // ===========================================================================
473
+ async get(id) {
474
+ const { items } = await this.collection();
475
+ const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }));
476
+ return doc ? this.decode(doc)[1] : undefined;
477
+ }
478
+ async getAll() {
479
+ return (await this.liveDocs()).map((doc) => this.decode(doc)[1]);
480
+ }
481
+ async getEntries() {
482
+ return (await this.liveDocs()).map((doc) => this.decode(doc));
483
+ }
484
+ async getIds() {
485
+ const { items } = await this.collection();
486
+ const docs = await items.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
487
+ return docs.map((doc) => String(doc[KEY]));
488
+ }
489
+ async find(predicate) {
490
+ return (await this.getAll()).filter(predicate);
491
+ }
492
+ async query(selector, options, policy) {
493
+ const rule = policy ?? this.options.queryPolicy;
494
+ // The reference checks the selector and the options first, without a read of the database
495
+ queryEntries([], selector, options, rule);
496
+ const filter = selectorPrefilter(selector);
497
+ const docs = await this.liveDocs(filter ? { $or: [{ [JSON_FIELD]: { $exists: true } }, filter] } : undefined);
498
+ return queryEntries(docs.map((doc) => this.decode(doc)), selector, options, rule);
499
+ }
500
+ async has(id) {
501
+ const { items } = await this.collection();
502
+ return (await items.countDocuments(this.liveFilter({ _id: this.storedId(id) }), { limit: 1 })) > 0;
503
+ }
504
+ async size() {
505
+ const { items } = await this.collection();
506
+ return items.countDocuments(this.liveFilter());
507
+ }
508
+ async getBatch(ids) {
509
+ const result = new Map();
510
+ if (ids.length === 0) {
511
+ this.checkOpen();
512
+ return result;
513
+ }
514
+ const { items } = await this.collection();
515
+ const docs = await items.find(this.liveFilter({ _id: { $in: ids.map((id) => this.storedId(id)) } })).toArray();
516
+ const byId = new Map(docs.map((doc) => this.decode(doc)));
517
+ // The order of the ids of the call
518
+ for (const id of ids)
519
+ if (byId.has(id))
520
+ result.set(id, byId.get(id));
521
+ return result;
522
+ }
523
+ /**
524
+ * This method runs a read-only aggregation pipeline on the items of the partition. The filter of
525
+ * the partition goes on each read of the pipeline, also in its sub-pipelines (`scopePipeline`),
526
+ * and the tombstones stay out. `$out`, `$merge`, the stages of the server and the JavaScript
527
+ * operators are refused. The pipeline reads the stored documents: an item stored flat has the
528
+ * fields of the storage beside its fields, and an item stored as JSON has only `_json`.
529
+ */
530
+ async aggregate(pipeline, limits = {}) {
531
+ const maxResults = limits.maxResults ?? DEFAULT_PIPELINE_LIMITS.maxResults;
532
+ const maxTimeMS = Math.round(toMs(limits.maxTime ?? DEFAULT_PIPELINE_LIMITS.maxTime, "maxTime"));
533
+ if (!Number.isSafeInteger(maxResults) || maxResults < 1)
534
+ throw new Error("maxResults must be a whole number of 1 or more");
535
+ if (!Number.isSafeInteger(maxTimeMS) || maxTimeMS < 1)
536
+ throw new Error("maxTime must be 1 ms or more");
537
+ // The pipeline reads only this collection: another collection needs its own right
538
+ const scopedPipeline = scopePipeline(pipeline, { match: { ...this.partitionFilter(), [TOMB]: { $ne: true } }, collections: [this.collectionName] });
539
+ const bounded = boundPipeline(scopedPipeline, maxResults);
540
+ const { items } = await this.collection();
541
+ const results = await items.aggregate(bounded.pipeline, { maxTimeMS }).toArray();
542
+ return cutResults(results, maxResults, bounded.limited);
543
+ }
544
+ /** The version of an item: the sequence number of its last write, or undefined for a missing id. */
545
+ async versionOf(id) {
546
+ const { items } = await this.collection();
547
+ const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { [SEQ]: 1 } });
548
+ return doc ? String(doc[SEQ]) : undefined;
549
+ }
550
+ async getChangesSince(since) {
551
+ const { db, items } = await this.collection();
552
+ return enqueue(db, this.collectionName, async () => {
553
+ const counter = await this.watermark(db);
554
+ const version = `${counter.epoch}.${counter.seq}`;
555
+ const full = async () => ({
556
+ version,
557
+ full: true,
558
+ changed: (await items.find(this.liveFilter()).sort({ [ORD]: 1 }).toArray()).map((doc) => this.decode(doc)),
559
+ deleted: [],
560
+ });
561
+ const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
562
+ if (!match || match[1] !== counter.epoch)
563
+ return full();
564
+ const from = Number(match[2]);
565
+ const floorDoc = await db.collection(this.countersName).findOne({ _id: this.floorId() });
566
+ const floor = typeof floorDoc?.["floor"] === "number" ? floorDoc["floor"] : 0;
567
+ if (from < floor || from > counter.seq)
568
+ return full();
569
+ // A number above the watermark can belong to a write that is not visible yet: the next read gives it
570
+ const docs = await items
571
+ .find({ ...this.partitionFilter(), [SEQ]: { $gt: from, $lte: counter.seq } })
572
+ .sort({ [SEQ]: 1 })
573
+ .toArray();
574
+ const changed = [];
575
+ const deleted = [];
576
+ for (const doc of docs) {
577
+ if (doc[TOMB] === true)
578
+ deleted.push(String(doc[KEY]));
579
+ else
580
+ changed.push(this.decode(doc));
581
+ }
582
+ return { version, full: false, changed, deleted };
583
+ });
584
+ }
585
+ // ===========================================================================
586
+ // Writes
587
+ // ===========================================================================
588
+ async write(task) {
589
+ const { db, items } = await this.collection();
590
+ return enqueue(db, this.collectionName, () => task(db, items));
591
+ }
592
+ async set(id, value, _options) {
593
+ await this.setBatch([[id, value]]);
594
+ }
595
+ async setBatch(items, _options) {
596
+ if (items.length === 0) {
597
+ this.checkOpen();
598
+ return;
599
+ }
600
+ // The ids and the encoding are checked before a number of the sequence goes
601
+ for (const [id, value] of items) {
602
+ this.storedId(id);
603
+ JSON.stringify(value);
604
+ }
605
+ await this.write(async (db, collection) => {
606
+ const places = await this.placesOf(collection, items.map(([id]) => id));
607
+ await this.withNumbers(db, collection, items.length, async (first, session) => {
608
+ const placed = new Map(places);
609
+ const operations = items.map(([id, value], index) => {
610
+ const seq = first + index;
611
+ // A new id takes its place at its first write; a later write of the batch keeps it
612
+ if (!placed.has(id))
613
+ placed.set(id, seq);
614
+ return { replaceOne: { filter: this.writeFilter(id), replacement: this.encode(id, value, seq, placed.get(id)), upsert: true } };
615
+ });
616
+ await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
617
+ });
618
+ });
619
+ }
620
+ async patch(id, fields, _options) {
621
+ return this.write(async (db, collection) => {
622
+ const doc = await collection.findOne(this.liveFilter({ _id: this.storedId(id) }));
623
+ if (!doc)
624
+ return undefined;
625
+ const next = applyPatch(this.decode(doc)[1], fields);
626
+ await this.withNumbers(db, collection, 1, (first, session) => collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, Number(doc[ORD])), { upsert: true, ...(session ? { session } : {}) }));
627
+ return next;
628
+ });
629
+ }
630
+ async delete(id, options) {
631
+ return (await this.deleteBatch([id], options)) > 0;
632
+ }
633
+ async deleteBatch(ids, _options) {
634
+ if (ids.length === 0) {
635
+ this.checkOpen();
636
+ return 0;
637
+ }
638
+ return this.write(async (db, collection) => {
639
+ const unique = [...new Set(ids)];
640
+ const live = await collection
641
+ .find(this.liveFilter({ _id: { $in: unique.map((id) => this.storedId(id)) } }), { projection: { _id: 1, [KEY]: 1 } })
642
+ .toArray();
643
+ if (live.length === 0)
644
+ return 0;
645
+ const liveIds = new Set(live.map((doc) => String(doc[KEY])));
646
+ const deleted = unique.filter((id) => liveIds.has(id));
647
+ await this.tombstone(db, collection, deleted);
648
+ return deleted.length;
649
+ });
650
+ }
651
+ async clear(_options) {
652
+ await this.write(async (db, collection) => {
653
+ const live = await collection.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
654
+ if (live.length === 0)
655
+ return;
656
+ await this.tombstone(db, collection, live.map((doc) => String(doc[KEY])));
657
+ });
658
+ }
659
+ /** This method replaces the items of the ids with tombstones, with new numbers, in the queue of the collection. */
660
+ async tombstone(db, collection, ids) {
661
+ await this.withNumbers(db, collection, ids.length, async (first, session) => {
662
+ const operations = ids.map((id, index) => {
663
+ const tomb = { _id: this.storedId(id), [KEY]: id, [SEQ]: first + index, [TOMB]: true };
664
+ if (this.partition !== undefined)
665
+ tomb[PART] = this.partition;
666
+ return { replaceOne: { filter: this.writeFilter(id), replacement: tomb } };
667
+ });
668
+ await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
669
+ });
670
+ await this.pruneTombstones(db, collection);
671
+ }
672
+ // ===========================================================================
673
+ // The life of the storage
674
+ // ===========================================================================
675
+ /**
676
+ * This method closes the storage. The connection belongs to the package or to the caller, so it
677
+ * stays open. After `close()`, each operation rejects, and a second `close()` does nothing.
678
+ */
679
+ async close() {
680
+ this.closed = true;
681
+ }
682
+ checkOpen() {
683
+ if (this.closed)
684
+ throw new Error(`The MongoDocumentStorage of the collection ${this.collectionName} is closed`);
685
+ }
686
+ async getMetadata() {
687
+ return {
688
+ type: "custom",
689
+ size: await this.size(),
690
+ stats: { collectionName: this.collectionName, ...(this.partition !== undefined ? { partition: this.partition } : {}) },
691
+ };
692
+ }
693
+ }
694
+ //# sourceMappingURL=document-storage.js.map