@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.
- package/CHANGELOG.md +69 -0
- package/README.md +61 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +2 -2
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +4 -5
- package/dist/module.js.map +1 -1
- package/dist/procedures/input-schemas.d.ts.map +1 -1
- package/dist/procedures/input-schemas.js +29 -2
- package/dist/procedures/input-schemas.js.map +1 -1
- package/dist/storage/document-storage.d.ts +338 -10
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +704 -75
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/index.d.ts +5 -3
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +4 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/materializer.d.ts +27 -12
- package/dist/storage/materializer.d.ts.map +1 -1
- package/dist/storage/materializer.js +44 -15
- package/dist/storage/materializer.js.map +1 -1
- package/dist/storage/own-writes.d.ts +51 -0
- package/dist/storage/own-writes.d.ts.map +1 -0
- package/dist/storage/own-writes.js +130 -0
- package/dist/storage/own-writes.js.map +1 -0
- package/dist/storage/storage-hints.d.ts +230 -0
- package/dist/storage/storage-hints.d.ts.map +1 -0
- package/dist/storage/storage-hints.js +876 -0
- package/dist/storage/storage-hints.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +15 -3
- package/src/module.ts +5 -5
- package/src/procedures/input-schemas.ts +29 -2
- package/src/storage/document-storage.ts +832 -75
- package/src/storage/index.ts +15 -3
- package/src/storage/materializer.ts +61 -22
- package/src/storage/own-writes.ts +157 -0
- package/src/storage/storage-hints.ts +971 -0
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* `{ _id, data }` documents.
|
|
5
5
|
*
|
|
6
6
|
* The documents:
|
|
7
|
-
* - An item that is a plain object
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* - An item that is a plain object is stored flat: its fields beside the fields of the storage. A
|
|
8
|
+
* key with `$` or a dot below the top level, and a top-level key with a dot, stay as they are
|
|
9
|
+
* (MongoDB 5.0 or later keeps them). Another item is stored as JSON text in `_json`: null, a
|
|
10
|
+
* primitive, an array, an object with a top-level key of the storage or a top-level key that
|
|
11
|
+
* starts with `$`, a key `__proto__`, or a class instance other than `Date`. Both ways give the
|
|
12
|
+
* item back as it was. An object with a text `$ref` and an `$id` is refused (`TypeError`):
|
|
13
|
+
* MongoDB gives it back as a `DBRef`.
|
|
11
14
|
* - An item whose `_id` is its id keeps it: the storage gives it back.
|
|
12
15
|
* - The fields of the storage: `_key` (the id), `_part` (the partition), `_seq` (the sequence
|
|
13
16
|
* number of the last write), `_ord` (the number of the first write: the order of the entries, as
|
|
@@ -16,6 +19,40 @@
|
|
|
16
19
|
* character U+001F. Each partition reads and writes only its documents: each write filter has the
|
|
17
20
|
* partition too, so a write of a stored `_id` of another partition fails and changes nothing.
|
|
18
21
|
*
|
|
22
|
+
* The stored form (roadmap CLI-76): an app with its own form of documents keeps it with the option
|
|
23
|
+
* `form`, one `StoredForm` object. `DEFAULT_STORED_FORM` gives the form above, byte for byte.
|
|
24
|
+
* - `storedId(partition, id)` gives the stored `_id`, and `idOf(storedId, partition)` is its inverse.
|
|
25
|
+
* The stored `_id` can be any value but `undefined` and `null`, for example `tenant:key`.
|
|
26
|
+
* - `partitionFields(partition)` gives the fields that name the partition (default `{ _part }`).
|
|
27
|
+
* Each document of the partition has them, and they are the filter of the partition. A field with
|
|
28
|
+
* the value `undefined` is absent: the document does not have it, and the filter is
|
|
29
|
+
* `{ $exists: false }`. An item can have a partition field (one that is not a field of the
|
|
30
|
+
* storage) with the value of its partition: the write leaves it out and notes it in `_kpf`, and
|
|
31
|
+
* the read gives it back. The same field with another value is refused (`TypeError`).
|
|
32
|
+
* - `partitionOf(doc)` gives the partition of a stored document, for `ViewMaterializer`. A delete
|
|
33
|
+
* event has only the `documentKey`: so the `_id` must tell the partition, or the collection needs
|
|
34
|
+
* `changeStreamPreAndPostImages`.
|
|
35
|
+
* - The storage adds its fields (`_key`, `_seq`, `_ord`, `_tomb`, and `_kid` or `_json` as needed)
|
|
36
|
+
* in each form. A reader of the raw documents can leave them out (`DOCUMENT_STORAGE_FIELDS`), and
|
|
37
|
+
* must leave out the tombstones (`TOMBSTONE_FIELD` is `true`). `decodeStoredDocument(doc)` does
|
|
38
|
+
* this, as the storage reads a document.
|
|
39
|
+
* - A document without the fields of the storage (for example one written before the app used the
|
|
40
|
+
* storage) is an item too. Its id comes from `idOf`, else from `_key`, and its item gets that id
|
|
41
|
+
* as `_id`. Its partition fields follow the rule of a written document without `_kpf`: a field
|
|
42
|
+
* with the value of the partition stays out, and a field with another value stays in. So the
|
|
43
|
+
* item has the same form before and after its first write. A document whose id neither
|
|
44
|
+
* tells (for example an `ObjectId` that `idOf` does not name) is not an item: the reads and
|
|
45
|
+
* `size()` leave it out. Its version is `0`, and it comes before the other items in the order of
|
|
46
|
+
* the entries (then by `_id`). A full result of the change log has it, and a delta has it after
|
|
47
|
+
* its first write through the storage. That write gives it its fields and a version, and keeps
|
|
48
|
+
* its place.
|
|
49
|
+
* - Write through the storage. A raw write (for example `findOneAndReplace` or a script) takes no
|
|
50
|
+
* number of the sequence, so a delta of the change log does not give it, and a raw replace drops
|
|
51
|
+
* `_seq`.
|
|
52
|
+
* - A raw writer must not hard-delete an item while the storage writes it. The write of a document
|
|
53
|
+
* without `_seq` is an upsert with `{ _seq: { $exists: false } }`: after a hard delete between
|
|
54
|
+
* the read and the write, the upsert inserts the document again.
|
|
55
|
+
*
|
|
19
56
|
* The change log:
|
|
20
57
|
* - Each write gets the next number of the sequence of the collection. `_seq` is also the version
|
|
21
58
|
* of the item (`versionOf`).
|
|
@@ -31,6 +68,9 @@
|
|
|
31
68
|
* its lease moves its documents to new numbers, so a reader that passed the old numbers gets
|
|
32
69
|
* it. One case stays on a single server: a process that stops between such a late write and the
|
|
33
70
|
* move of its documents. Then a reader that passed the old numbers does not get that change.
|
|
71
|
+
* - The storage uses a transaction only with a client: the option `client`, or the default
|
|
72
|
+
* connection when `db` is absent. With `db` and no `client`, each write uses the watermark, also
|
|
73
|
+
* on a replica set.
|
|
34
74
|
*
|
|
35
75
|
* The queries: `query()` sends the conditions that MongoDB tests in the same way (the equality of
|
|
36
76
|
* a string, a number or a boolean, and `$in` of them) as a filter. `queryEntries()` of the core
|
|
@@ -45,11 +85,18 @@
|
|
|
45
85
|
* precondition has the `_seq` that the test read in its filter (a compare-and-set). Another writer
|
|
46
86
|
* between the test and the write makes the write fail with `VersionConflictError`. Then a batch of
|
|
47
87
|
* more than one item can keep a part of its writes: only a transaction makes a batch all or nothing.
|
|
88
|
+
* One case stays on a single server: a writer outside the storage that writes a document without
|
|
89
|
+
* `_seq` between the test and the write. The guard of such a document is `{ _seq: { $exists:
|
|
90
|
+
* false } }`, so it does not see that writer. On a replica set, the test and the write are one
|
|
91
|
+
* transaction, and this case does not occur.
|
|
48
92
|
*/
|
|
49
93
|
import { randomUUID } from "node:crypto";
|
|
94
|
+
import { isDeepStrictEqual } from "node:util";
|
|
95
|
+
import { BSON } from "mongodb";
|
|
50
96
|
import { VersionConflictError, applyPatch, preconditionNotSupported, queryEntries, versionConflict, } from "@procedures/collections";
|
|
51
97
|
import { optionalMs, toMs } from "@procedures/client";
|
|
52
98
|
import { ensureConnection } from "../connection.js";
|
|
99
|
+
import { noteOwnNumbers, notePrunedIds } from "./own-writes.js";
|
|
53
100
|
import { boundPipeline, cutResults, DEFAULT_PIPELINE_LIMITS, scopePipeline } from "./pipeline-scope.js";
|
|
54
101
|
/** The fields of the storage in a stored document. */
|
|
55
102
|
const KEY = "_key";
|
|
@@ -61,9 +108,47 @@ const TOMB = "_tomb";
|
|
|
61
108
|
const JSON_FIELD = "_json";
|
|
62
109
|
/** True in a stored document whose item had `_id` equal to its id. */
|
|
63
110
|
const KEEP_ID = "_kid";
|
|
64
|
-
|
|
111
|
+
/** The partition fields that the item had with the value of its partition (roadmap CLI-76). The write leaves them out of the fields. */
|
|
112
|
+
const KEEP_PART = "_kpf";
|
|
113
|
+
const STORAGE_FIELDS = new Set(["_id", KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
65
114
|
/** The separator of the partition and the id in a stored `_id`. */
|
|
66
115
|
export const SEPARATOR = "\u001f";
|
|
116
|
+
/**
|
|
117
|
+
* The fields that a `MongoDocumentStorage` can add to a stored document, in each stored form
|
|
118
|
+
* (roadmap CLI-76). A reader of the raw documents can leave them out. The partition fields are
|
|
119
|
+
* not in the list.
|
|
120
|
+
*/
|
|
121
|
+
export const DOCUMENT_STORAGE_FIELDS = Object.freeze([KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
122
|
+
/** The field that is `true` in a tombstone: the document of a deleted item. A reader of the raw documents leaves them out. */
|
|
123
|
+
export const TOMBSTONE_FIELD = TOMB;
|
|
124
|
+
/**
|
|
125
|
+
* The default stored form, the form of 0.34.0. The stored `_id` is the id, or `<partition>` +
|
|
126
|
+
* U+001F + `<id>` with a partition. The partition field is `_part` (absent without a partition).
|
|
127
|
+
* `partitionOf` reads `_part`, else the part of `_id` before U+001F.
|
|
128
|
+
*/
|
|
129
|
+
export const DEFAULT_STORED_FORM = Object.freeze({
|
|
130
|
+
storedId(partition, id) {
|
|
131
|
+
return partition === undefined ? id : `${partition}${SEPARATOR}${id}`;
|
|
132
|
+
},
|
|
133
|
+
idOf(storedId, partition) {
|
|
134
|
+
if (typeof storedId !== "string")
|
|
135
|
+
return undefined;
|
|
136
|
+
if (partition === undefined)
|
|
137
|
+
return storedId.includes(SEPARATOR) ? undefined : storedId;
|
|
138
|
+
const prefix = `${partition}${SEPARATOR}`;
|
|
139
|
+
return storedId.startsWith(prefix) ? storedId.slice(prefix.length) : undefined;
|
|
140
|
+
},
|
|
141
|
+
partitionFields(partition) {
|
|
142
|
+
return { [PART]: partition };
|
|
143
|
+
},
|
|
144
|
+
partitionOf(doc) {
|
|
145
|
+
if (typeof doc[PART] === "string")
|
|
146
|
+
return doc[PART];
|
|
147
|
+
const id = doc["_id"];
|
|
148
|
+
const at = typeof id === "string" ? id.indexOf(SEPARATOR) : -1;
|
|
149
|
+
return at > 0 ? id.slice(0, at) : undefined;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
67
152
|
/** True for the error of a duplicate key: an insert of an upsert whose filter found no document. */
|
|
68
153
|
function isDuplicateKey(error) {
|
|
69
154
|
// A bulk write error has the code of its first write error, and the list of the write errors
|
|
@@ -102,25 +187,143 @@ function enqueue(db, collection, task) {
|
|
|
102
187
|
});
|
|
103
188
|
return run;
|
|
104
189
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
190
|
+
/**
|
|
191
|
+
* True for a value of a field that BSON gives back as it is: a key with `$` or a dot stays as it is
|
|
192
|
+
* (MongoDB 5.0 or later). False when only JSON text keeps it: a class instance other than `Date`,
|
|
193
|
+
* or a key `__proto__`. An object that BSON gives back as a `DBRef` adds a problem.
|
|
194
|
+
*/
|
|
195
|
+
function isNativeValue(value, path, problems) {
|
|
107
196
|
if (Array.isArray(value))
|
|
108
|
-
return value.every((element) =>
|
|
197
|
+
return value.every((element, index) => isNativeValue(element, `${path}.${index}`, problems));
|
|
109
198
|
if (typeof value !== "object" || value === null)
|
|
110
199
|
return true;
|
|
111
200
|
const proto = Object.getPrototypeOf(value);
|
|
112
201
|
if (proto !== Object.prototype && proto !== null)
|
|
113
202
|
return value instanceof Date;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
203
|
+
const object = value;
|
|
204
|
+
// The test of the BSON reader: a text `$ref`, an `$id`, and no `$db` or a text `$db`
|
|
205
|
+
if (typeof object["$ref"] === "string" && object["$id"] !== undefined && object["$id"] !== null && (object["$db"] === undefined || typeof object["$db"] === "string")) {
|
|
206
|
+
problems.push(`the field ${path} has $ref and $id, and MongoDB gives it back as a DBRef`);
|
|
207
|
+
}
|
|
208
|
+
for (const [key, field] of Object.entries(object)) {
|
|
209
|
+
if (key === "__proto__")
|
|
118
210
|
return false;
|
|
119
|
-
if (!
|
|
211
|
+
if (!isNativeValue(field, `${path}.${key}`, problems))
|
|
120
212
|
return false;
|
|
121
213
|
}
|
|
122
214
|
return true;
|
|
123
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* The item of a stored document (not a tombstone) whose id is known. `_json` gives the item as it
|
|
218
|
+
* was. Else the fields of the storage stay out, the item gets `_id` when it had it (`_kid`), when
|
|
219
|
+
* the document is from outside the storage (no `_key`) or with `addId`, and it gets back the
|
|
220
|
+
* partition fields of `_kpf`. A partition field with the value of the partition stays out (for a
|
|
221
|
+
* document from outside the storage too); a field with another value stays in.
|
|
222
|
+
*/
|
|
223
|
+
function itemOf(doc, id, partitionEntries, addId) {
|
|
224
|
+
if (typeof doc[JSON_FIELD] === "string") {
|
|
225
|
+
const item = JSON.parse(doc[JSON_FIELD]);
|
|
226
|
+
if (addId && isPlainObject(item) && !("_id" in item))
|
|
227
|
+
return { _id: id, ...item };
|
|
228
|
+
return item;
|
|
229
|
+
}
|
|
230
|
+
const outside = doc[KEY] === undefined;
|
|
231
|
+
const value = {};
|
|
232
|
+
if (addId || outside || doc[KEEP_ID] === true)
|
|
233
|
+
value["_id"] = id;
|
|
234
|
+
const partitionValues = new Map(partitionEntries);
|
|
235
|
+
for (const [key, field] of Object.entries(doc)) {
|
|
236
|
+
if (STORAGE_FIELDS.has(key))
|
|
237
|
+
continue;
|
|
238
|
+
// The value of the partition comes back only through `_kpf`, so the item has one form at each age of its document
|
|
239
|
+
if (partitionValues.has(key) && isDeepStrictEqual(field, partitionValues.get(key)))
|
|
240
|
+
continue;
|
|
241
|
+
put(value, key, field);
|
|
242
|
+
}
|
|
243
|
+
const kept = doc[KEEP_PART];
|
|
244
|
+
if (Array.isArray(kept)) {
|
|
245
|
+
for (const [name, field] of partitionEntries)
|
|
246
|
+
if (kept.includes(name) && !STORAGE_FIELDS.has(name))
|
|
247
|
+
put(value, name, field);
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* This function reads a raw document of a `MongoDocumentStorage` collection as the storage reads
|
|
253
|
+
* it (roadmap CLI-76), for an app that reads the collection directly (a query, a view, an
|
|
254
|
+
* aggregation). It gives `undefined` for a tombstone and for a document whose id neither `idOf`
|
|
255
|
+
* nor `_key` tells. Else it parses `_json`, leaves out the fields of the storage
|
|
256
|
+
* (`DOCUMENT_STORAGE_FIELDS`) and gives the item its id as `_id` (see `addId`).
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```typescript
|
|
260
|
+
* const items = docs.flatMap((doc) => decodeStoredDocument<Note>(doc, { form: notes.form })?.item ?? []);
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
export function decodeStoredDocument(doc, options = {}) {
|
|
264
|
+
if (!isPlainObject(doc) || doc[TOMB] === true)
|
|
265
|
+
return undefined;
|
|
266
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
267
|
+
const partition = options.partition ?? form.partitionOf(doc);
|
|
268
|
+
const fromId = form.idOf(doc["_id"], partition);
|
|
269
|
+
const id = typeof fromId === "string" ? fromId : typeof doc[KEY] === "string" ? doc[KEY] : undefined;
|
|
270
|
+
if (id === undefined)
|
|
271
|
+
return undefined;
|
|
272
|
+
const entries = Object.entries(form.partitionFields(partition));
|
|
273
|
+
return { id, partition, item: itemOf(doc, id, entries, options.addId ?? true) };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* This function gives the version of a raw stored document of a `MongoDocumentStorage`: the
|
|
277
|
+
* version that `storage.versionOf(id)` gives for the same document (roadmap CLI-77). The storage
|
|
278
|
+
* uses it too, so the two never differ.
|
|
279
|
+
*
|
|
280
|
+
* - The version is the sequence number of the last write through the storage (`_seq`), as text.
|
|
281
|
+
* - A document without `_seq` (an old document, or one that a raw replace wrote) has the version `0`.
|
|
282
|
+
* - A tombstone, a value that is not a plain object, and a document whose id neither `idOf` nor
|
|
283
|
+
* `_key` tells give `undefined`: they are no items, as for `decodeStoredDocument`.
|
|
284
|
+
*
|
|
285
|
+
* @param doc - The stored document, for example the `fullDocument` of a change event
|
|
286
|
+
* @param form - The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`)
|
|
287
|
+
* @param partition - The partition of the document. The default is `form.partitionOf(doc)`.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* const version = versionOfStored(change.fullDocument, notes.form); // the same as await notes.versionOf(id)
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export function versionOfStored(doc, form = DEFAULT_STORED_FORM, partition) {
|
|
295
|
+
if (!isPlainObject(doc) || doc[TOMB] === true)
|
|
296
|
+
return undefined;
|
|
297
|
+
const place = partition ?? form.partitionOf(doc);
|
|
298
|
+
if (typeof form.idOf(doc["_id"], place) !== "string" && typeof doc[KEY] !== "string")
|
|
299
|
+
return undefined;
|
|
300
|
+
return seqVersion(doc);
|
|
301
|
+
}
|
|
302
|
+
/** The version of the `_seq` of a stored document: the number as text, or `0` without it. */
|
|
303
|
+
function seqVersion(doc) {
|
|
304
|
+
const seq = doc[SEQ];
|
|
305
|
+
return typeof seq === "number" ? String(seq) : "0";
|
|
306
|
+
}
|
|
307
|
+
/** True when two values have the same BSON. */
|
|
308
|
+
function sameBson(a, b) {
|
|
309
|
+
try {
|
|
310
|
+
return Buffer.from(BSON.serialize({ v: a })).equals(Buffer.from(BSON.serialize({ v: b })));
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** The most floor documents that `partitions()` reads. */
|
|
317
|
+
const MAX_KNOWN_PARTITIONS = 10_000;
|
|
318
|
+
function escapeRegex(text) {
|
|
319
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
320
|
+
}
|
|
321
|
+
/** The reader of the parts of a storage. The class sets it, so it can read its private fields. */
|
|
322
|
+
let sourceOfStorage;
|
|
323
|
+
/** This function gives the parts of a storage that `watchStorageChanges` reads. The package does not export it. */
|
|
324
|
+
export function storageChangeSource(storage) {
|
|
325
|
+
return sourceOfStorage(storage);
|
|
326
|
+
}
|
|
124
327
|
/** True for a plain object. */
|
|
125
328
|
function isPlainObject(value) {
|
|
126
329
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -137,17 +340,47 @@ function isScalar(value) {
|
|
|
137
340
|
return typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
138
341
|
}
|
|
139
342
|
/** The field of a stored document for a path of a selector, or undefined when the filter cannot test it. */
|
|
140
|
-
function storedPath(path) {
|
|
343
|
+
function storedPath(path, reserved) {
|
|
141
344
|
if (path === "_id")
|
|
142
345
|
return KEY;
|
|
143
|
-
const
|
|
144
|
-
|
|
346
|
+
const parts = path.split(".");
|
|
347
|
+
const first = parts[0];
|
|
348
|
+
if (first === "_id" || STORAGE_FIELDS.has(first) || reserved.has(first))
|
|
349
|
+
return undefined;
|
|
350
|
+
// A part with `$` is a field name of an item, but an operator for MongoDB
|
|
351
|
+
if (parts.some((part) => part.startsWith("$")))
|
|
145
352
|
return undefined;
|
|
146
353
|
return path;
|
|
147
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* The filter of a condition on `_id` against the stored `_id`, or undefined. A flat item with an
|
|
357
|
+
* `_id` has its id as `_id`, and an item stored as JSON passes the filter of `query()` anyway.
|
|
358
|
+
*/
|
|
359
|
+
function idConditionFilter(condition, storedId) {
|
|
360
|
+
try {
|
|
361
|
+
if (typeof condition === "string")
|
|
362
|
+
return { _id: storedId(condition) };
|
|
363
|
+
if (!isPlainObject(condition))
|
|
364
|
+
return undefined;
|
|
365
|
+
const operators = {};
|
|
366
|
+
const eq = condition["$eq"];
|
|
367
|
+
if (typeof eq === "string")
|
|
368
|
+
operators["$eq"] = storedId(eq);
|
|
369
|
+
const among = condition["$in"];
|
|
370
|
+
if (Array.isArray(among) && among.length > 0 && among.every((value) => typeof value === "string"))
|
|
371
|
+
operators["$in"] = among.map((value) => storedId(value));
|
|
372
|
+
return Object.keys(operators).length > 0 ? { _id: operators } : undefined;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// An id that the storage refuses: no condition is a correct filter (it can pass more)
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
148
379
|
/** The filter of one condition, or undefined when the filter cannot test it in the same way. */
|
|
149
|
-
function conditionFilter(path, condition) {
|
|
150
|
-
|
|
380
|
+
function conditionFilter(path, condition, options, reserved) {
|
|
381
|
+
if (path === "_id" && options.storedId)
|
|
382
|
+
return idConditionFilter(condition, options.storedId);
|
|
383
|
+
const field = storedPath(path, reserved);
|
|
151
384
|
if (field === undefined)
|
|
152
385
|
return undefined;
|
|
153
386
|
if (isScalar(condition))
|
|
@@ -167,26 +400,29 @@ function conditionFilter(path, condition) {
|
|
|
167
400
|
* This function gives a filter that each item of the selector passes (a filter can pass more),
|
|
168
401
|
* or undefined for no condition. Only conditions that MongoDB tests in the same way go in it.
|
|
169
402
|
*/
|
|
170
|
-
export function selectorPrefilter(selector) {
|
|
403
|
+
export function selectorPrefilter(selector, options = {}) {
|
|
404
|
+
return prefilter(selector, options, new Set(options.storageFields ?? []));
|
|
405
|
+
}
|
|
406
|
+
function prefilter(selector, options, reserved) {
|
|
171
407
|
const parts = [];
|
|
172
408
|
for (const [key, condition] of Object.entries(selector)) {
|
|
173
409
|
if (condition === undefined)
|
|
174
410
|
continue;
|
|
175
411
|
if (key === "$and" && Array.isArray(condition)) {
|
|
176
412
|
for (const branch of condition) {
|
|
177
|
-
const filter = isPlainObject(branch) ?
|
|
413
|
+
const filter = isPlainObject(branch) ? prefilter(branch, options, reserved) : undefined;
|
|
178
414
|
if (filter)
|
|
179
415
|
parts.push(filter);
|
|
180
416
|
}
|
|
181
417
|
}
|
|
182
418
|
else if (key === "$or" && Array.isArray(condition) && condition.length > 0) {
|
|
183
|
-
const branches = condition.map((branch) => (isPlainObject(branch) ?
|
|
419
|
+
const branches = condition.map((branch) => (isPlainObject(branch) ? prefilter(branch, options, reserved) : undefined));
|
|
184
420
|
// A branch without a condition passes each item: then the $or tests nothing
|
|
185
421
|
if (branches.every((branch) => branch !== undefined))
|
|
186
422
|
parts.push({ $or: branches });
|
|
187
423
|
}
|
|
188
424
|
else if (!key.startsWith("$")) {
|
|
189
|
-
const filter = conditionFilter(key, condition);
|
|
425
|
+
const filter = conditionFilter(key, condition, options, reserved);
|
|
190
426
|
if (filter)
|
|
191
427
|
parts.push(filter);
|
|
192
428
|
}
|
|
@@ -214,12 +450,53 @@ export class MongoDocumentStorage {
|
|
|
214
450
|
writeLeaseMs;
|
|
215
451
|
/** The storages of the partitions, for `forPartition`: the same storage for the same partition. */
|
|
216
452
|
partitions;
|
|
453
|
+
/** The stored form of the documents (roadmap CLI-76). A `ViewMaterializer` of the collection takes the same form. */
|
|
454
|
+
form;
|
|
455
|
+
/** The fields that name the partition, in their order. An `undefined` value is an absent field. */
|
|
456
|
+
partitionEntries;
|
|
457
|
+
/** The values of the partition fields that are not fields of the storage, by name. */
|
|
458
|
+
partitionValues;
|
|
459
|
+
/** The fields that only the storage writes: its fields and the partition fields. */
|
|
460
|
+
reserved;
|
|
217
461
|
indexes;
|
|
462
|
+
/** True after this storage wrote (or found) the floor document of its partition (roadmap O66). A failed write leaves it false. */
|
|
463
|
+
floorNoted = false;
|
|
218
464
|
closed = false;
|
|
465
|
+
static {
|
|
466
|
+
sourceOfStorage = (storage) => ({
|
|
467
|
+
db: () => storage.database(),
|
|
468
|
+
collection: storage.collectionName,
|
|
469
|
+
counters: storage.countersName,
|
|
470
|
+
form: storage.form,
|
|
471
|
+
partition: storage.partition,
|
|
472
|
+
adopt: (raw) => storage.adoptRaw(raw),
|
|
473
|
+
adoptAll: (raws) => storage.adoptRawAll(raws),
|
|
474
|
+
rawDocuments: (after, limit) => storage.rawDocuments(after, limit),
|
|
475
|
+
storedDocuments: (ids) => storage.storedDocuments(ids),
|
|
476
|
+
partitions: () => storage.knownPartitions(),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
219
479
|
constructor(options, partitions) {
|
|
220
480
|
if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
|
|
221
481
|
throw new Error("A partition must be a text without the character U+001F");
|
|
222
482
|
}
|
|
483
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
484
|
+
for (const name of ["storedId", "idOf", "partitionFields", "partitionOf"]) {
|
|
485
|
+
if (typeof form[name] !== "function")
|
|
486
|
+
throw new TypeError(`The stored form must have the function ${name}`);
|
|
487
|
+
}
|
|
488
|
+
this.form = form;
|
|
489
|
+
const fields = form.partitionFields(options.partition);
|
|
490
|
+
if (!isPlainObject(fields) || Object.keys(fields).length === 0)
|
|
491
|
+
throw new TypeError("partitionFields must give an object with at least one field");
|
|
492
|
+
for (const name of Object.keys(fields)) {
|
|
493
|
+
if (name === "_id" || name.startsWith("$") || name.includes(".") || name === "__proto__" || (STORAGE_FIELDS.has(name) && name !== PART)) {
|
|
494
|
+
throw new TypeError(`partitionFields cannot have the field ${JSON.stringify(name)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
this.partitionEntries = Object.entries(fields);
|
|
498
|
+
this.partitionValues = new Map(this.partitionEntries.filter(([name]) => !STORAGE_FIELDS.has(name)));
|
|
499
|
+
this.reserved = new Set([...STORAGE_FIELDS, ...Object.keys(fields)]);
|
|
223
500
|
this.options = options;
|
|
224
501
|
this.collectionName = options.collection;
|
|
225
502
|
this.partition = options.partition;
|
|
@@ -251,8 +528,9 @@ export class MongoDocumentStorage {
|
|
|
251
528
|
async collection() {
|
|
252
529
|
const db = await this.database();
|
|
253
530
|
const items = db.collection(this.collectionName);
|
|
531
|
+
const prefix = Object.fromEntries(this.partitionEntries.map(([name]) => [name, 1]));
|
|
254
532
|
this.indexes ??= items
|
|
255
|
-
.createIndexes([{ key: {
|
|
533
|
+
.createIndexes([{ key: { ...prefix, [SEQ]: 1 } }, { key: { ...prefix, [ORD]: 1 } }, { key: { ...prefix, [TOMB]: 1 } }])
|
|
256
534
|
.then(() => undefined)
|
|
257
535
|
.catch((error) => {
|
|
258
536
|
this.indexes = undefined;
|
|
@@ -263,7 +541,15 @@ export class MongoDocumentStorage {
|
|
|
263
541
|
}
|
|
264
542
|
/** The filter of the documents of the partition. */
|
|
265
543
|
partitionFilter() {
|
|
266
|
-
|
|
544
|
+
// An object value is an equality too, never a set of operators
|
|
545
|
+
const condition = (value) => (value === undefined ? { $exists: false } : typeof value === "object" && value !== null ? { $eq: value } : value);
|
|
546
|
+
return Object.fromEntries(this.partitionEntries.map(([name, value]) => [name, condition(value)]));
|
|
547
|
+
}
|
|
548
|
+
/** This method puts the partition fields on a stored document, in their order. An absent field stays absent. */
|
|
549
|
+
putPartition(doc) {
|
|
550
|
+
for (const [name, value] of this.partitionEntries)
|
|
551
|
+
if (value !== undefined)
|
|
552
|
+
put(doc, name, value);
|
|
267
553
|
}
|
|
268
554
|
/** The filter of the items (not the tombstones) of the partition. */
|
|
269
555
|
liveFilter(extra) {
|
|
@@ -273,7 +559,23 @@ export class MongoDocumentStorage {
|
|
|
273
559
|
storedId(id) {
|
|
274
560
|
if (typeof id !== "string" || id.includes(SEPARATOR))
|
|
275
561
|
throw new Error("An id must be a text without the character U+001F");
|
|
276
|
-
|
|
562
|
+
const stored = this.form.storedId(this.partition, id);
|
|
563
|
+
if (stored === undefined || stored === null)
|
|
564
|
+
throw new TypeError(`storedId gave ${String(stored)} for the id ${JSON.stringify(id)}`);
|
|
565
|
+
return stored;
|
|
566
|
+
}
|
|
567
|
+
/** The id of a stored document: the inverse of its stored `_id`, else `_key`, or undefined when neither tells it. */
|
|
568
|
+
idOfDoc(doc) {
|
|
569
|
+
const id = this.form.idOf(doc["_id"], this.partition);
|
|
570
|
+
if (typeof id === "string")
|
|
571
|
+
return id;
|
|
572
|
+
const key = doc[KEY];
|
|
573
|
+
return typeof key === "string" ? key : undefined;
|
|
574
|
+
}
|
|
575
|
+
/** The place of a stored document in the order of the entries. A document without `_ord` comes first. */
|
|
576
|
+
placeOf(doc) {
|
|
577
|
+
const ord = doc[ORD];
|
|
578
|
+
return typeof ord === "number" ? ord : 0;
|
|
277
579
|
}
|
|
278
580
|
/** The filter of a write of one stored document: its `_id` and the partition. */
|
|
279
581
|
writeFilter(id) {
|
|
@@ -282,33 +584,72 @@ export class MongoDocumentStorage {
|
|
|
282
584
|
/** The stored document of an item, with the number of this write and the number of its place. */
|
|
283
585
|
encode(id, value, seq, ord) {
|
|
284
586
|
const doc = { _id: this.storedId(id), [KEY]: id, [SEQ]: seq, [ORD]: ord };
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (keepsId)
|
|
587
|
+
this.putPartition(doc);
|
|
588
|
+
const flat = this.flatOf(id, value);
|
|
589
|
+
if (flat) {
|
|
590
|
+
if (flat.keepsId)
|
|
290
591
|
doc[KEEP_ID] = true;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
592
|
+
if (flat.kept.length > 0)
|
|
593
|
+
doc[KEEP_PART] = flat.kept;
|
|
594
|
+
for (const [key, field] of flat.fields)
|
|
595
|
+
put(doc, key, field);
|
|
294
596
|
}
|
|
295
597
|
else {
|
|
296
598
|
doc[JSON_FIELD] = JSON.stringify(value);
|
|
297
599
|
}
|
|
298
600
|
return doc;
|
|
299
601
|
}
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
602
|
+
/**
|
|
603
|
+
* The fields of the flat document of an item, or undefined when only JSON text keeps it: an item
|
|
604
|
+
* that is not a plain object, an `_id` other than its id, a top-level key of the storage or with
|
|
605
|
+
* `$`, a key `__proto__`, a class instance other than `Date`. It throws a `TypeError` for a flat
|
|
606
|
+
* item that a document cannot keep: a partition field with another value than the partition, or
|
|
607
|
+
* an object that MongoDB gives back as a `DBRef`.
|
|
608
|
+
*/
|
|
609
|
+
flatOf(id, value) {
|
|
610
|
+
if (!isPlainObject(value))
|
|
611
|
+
return undefined;
|
|
612
|
+
if ("_id" in value && value["_id"] !== id)
|
|
613
|
+
return undefined;
|
|
614
|
+
const flat = { keepsId: "_id" in value, kept: [], fields: [] };
|
|
615
|
+
const problems = [];
|
|
616
|
+
for (const [key, field] of Object.entries(value)) {
|
|
617
|
+
if (key === "_id")
|
|
618
|
+
continue;
|
|
619
|
+
if (key.startsWith("$") || key === "__proto__" || STORAGE_FIELDS.has(key))
|
|
620
|
+
return undefined;
|
|
621
|
+
if (this.partitionValues.has(key)) {
|
|
622
|
+
// The partition field restores it on read; another value would put the document in another partition
|
|
623
|
+
if (isDeepStrictEqual(field, this.partitionValues.get(key)))
|
|
624
|
+
flat.kept.push(key);
|
|
625
|
+
else
|
|
626
|
+
problems.push(`the field ${key} names the partition and has another value than the partition`);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (!isNativeValue(field, key, problems))
|
|
630
|
+
return undefined;
|
|
631
|
+
flat.fields.push([key, field]);
|
|
632
|
+
}
|
|
633
|
+
if (problems.length > 0)
|
|
634
|
+
throw new TypeError(`The storage cannot keep the item ${JSON.stringify(id)}: ${problems.join("; ")}`);
|
|
635
|
+
return flat;
|
|
636
|
+
}
|
|
637
|
+
/** The id and the item of a stored document, or undefined when the document does not tell its id. */
|
|
638
|
+
decode(doc, known) {
|
|
639
|
+
const id = this.idOfDoc(doc) ?? known;
|
|
640
|
+
if (id === undefined)
|
|
641
|
+
return undefined;
|
|
642
|
+
return [id, itemOf(doc, id, this.partitionEntries, false)];
|
|
643
|
+
}
|
|
644
|
+
/** The entries of stored documents, in their order. A document that does not tell its id is left out. */
|
|
645
|
+
decodeAll(docs) {
|
|
646
|
+
const entries = [];
|
|
647
|
+
for (const doc of docs) {
|
|
648
|
+
const entry = this.decode(doc);
|
|
649
|
+
if (entry)
|
|
650
|
+
entries.push(entry);
|
|
651
|
+
}
|
|
652
|
+
return entries;
|
|
312
653
|
}
|
|
313
654
|
/**
|
|
314
655
|
* The next `count` numbers of the sequence of the collection. They stay pending in the counter
|
|
@@ -377,9 +718,11 @@ export class MongoDocumentStorage {
|
|
|
377
718
|
/**
|
|
378
719
|
* This method takes numbers and writes with them. On a replica set, the two are one
|
|
379
720
|
* transaction. Else the numbers stay pending until the write ends, and a late write moves its
|
|
380
|
-
* documents to new numbers.
|
|
721
|
+
* documents to new numbers. With `own` false (an adoption of a raw write, D39), a watcher of this
|
|
722
|
+
* process does not take the numbers as its own.
|
|
381
723
|
*/
|
|
382
|
-
async withNumbers(db, collection, count, write) {
|
|
724
|
+
async withNumbers(db, collection, count, write, own = true) {
|
|
725
|
+
const noteOwn = own ? noteOwnNumbers : () => undefined;
|
|
383
726
|
const client = await this.transactionClient(db);
|
|
384
727
|
if (client) {
|
|
385
728
|
const session = client.startSession();
|
|
@@ -388,6 +731,8 @@ export class MongoDocumentStorage {
|
|
|
388
731
|
const counter = (await db.collection(this.countersName).findOneAndUpdate({ _id: this.collectionName }, { $inc: { seq: count }, $setOnInsert: { epoch: randomUUID() } }, { upsert: true, returnDocument: "after", session }));
|
|
389
732
|
if (!counter)
|
|
390
733
|
throw new Error(`The sequence of ${this.collectionName} is not available`);
|
|
734
|
+
// A watcher of the collection in this process skips the events of these numbers (roadmap CLI-77)
|
|
735
|
+
noteOwn(db, this.collectionName, counter.seq - count + 1, count);
|
|
391
736
|
return write(counter.seq - count + 1, session);
|
|
392
737
|
});
|
|
393
738
|
}
|
|
@@ -396,6 +741,7 @@ export class MongoDocumentStorage {
|
|
|
396
741
|
}
|
|
397
742
|
}
|
|
398
743
|
let { first } = await this.allocate(db, count);
|
|
744
|
+
noteOwn(db, this.collectionName, first, count);
|
|
399
745
|
let result;
|
|
400
746
|
try {
|
|
401
747
|
result = await write(first);
|
|
@@ -409,6 +755,7 @@ export class MongoDocumentStorage {
|
|
|
409
755
|
throw new Error(`A write of ${this.collectionName} stayed late after ${MAX_MOVES} moves to new numbers`);
|
|
410
756
|
// Late: a reader can have passed these numbers. The write takes new ones.
|
|
411
757
|
const next = (await this.allocate(db, count)).first;
|
|
758
|
+
noteOwn(db, this.collectionName, next, count);
|
|
412
759
|
await this.move(collection, first, count, next);
|
|
413
760
|
first = next;
|
|
414
761
|
}
|
|
@@ -451,6 +798,33 @@ export class MongoDocumentStorage {
|
|
|
451
798
|
floorId() {
|
|
452
799
|
return `floor${SEPARATOR}${this.collectionName}${SEPARATOR}${this.partition ?? ""}`;
|
|
453
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* This method writes the floor document of the partition once for this storage, before its first
|
|
803
|
+
* write (roadmap O66). Then `knownPartitions()` of each process finds the partition, also without
|
|
804
|
+
* a prune, and a gap of `watchStorageChanges()` sends it a reset hint.
|
|
805
|
+
*
|
|
806
|
+
* - The floor `0` is neutral: `getChangesSince` gives a full result only for a version below the
|
|
807
|
+
* floor, and a version is never below `0`. A missing floor document also reads as `0`. So the
|
|
808
|
+
* document changes no result of the change log.
|
|
809
|
+
* - `$setOnInsert` writes only a new document: a floor of a prune (`$max`) stays as it is.
|
|
810
|
+
* - A duplicate key (another process inserted the same document first) is a success.
|
|
811
|
+
* - The upsert runs in the queue of the writes, before the task, so never in the transaction of
|
|
812
|
+
* `withNumbers()`: it adds no write conflict to the transaction of the user, and a transaction
|
|
813
|
+
* that tries again does not write it again.
|
|
814
|
+
* - A failure does not fail the write of the user. The flag stays false, so the next write tries again.
|
|
815
|
+
*/
|
|
816
|
+
async noteFloor(db) {
|
|
817
|
+
if (this.floorNoted)
|
|
818
|
+
return;
|
|
819
|
+
try {
|
|
820
|
+
await db.collection(this.countersName).updateOne({ _id: this.floorId() }, { $setOnInsert: { floor: 0 } }, { upsert: true });
|
|
821
|
+
this.floorNoted = true;
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
if (isDuplicateKey(error))
|
|
825
|
+
this.floorNoted = true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
454
828
|
/** This method drops the oldest tombstones of the partition over the limit, and keeps the floor of the log. */
|
|
455
829
|
async pruneTombstones(db, items) {
|
|
456
830
|
const count = await items.countDocuments({ ...this.partitionFilter(), [TOMB]: true });
|
|
@@ -464,21 +838,39 @@ export class MongoDocumentStorage {
|
|
|
464
838
|
.toArray();
|
|
465
839
|
if (oldest.length === 0)
|
|
466
840
|
return;
|
|
841
|
+
// A watcher of this process reads the delete of a pruned tombstone without the image before the change (roadmap CLI-77)
|
|
842
|
+
notePrunedIds(db, this.collectionName, oldest.map((doc) => doc._id));
|
|
467
843
|
await items.deleteMany({ _id: { $in: oldest.map((doc) => doc._id) } });
|
|
468
844
|
const floor = Math.max(...oldest.map((doc) => Number(doc[SEQ])));
|
|
469
845
|
await db.collection(this.countersName).updateOne({ _id: this.floorId() }, { $max: { floor } }, { upsert: true });
|
|
470
846
|
}
|
|
847
|
+
/** The ids of stored documents, in their order. A document that does not tell its id is left out. */
|
|
848
|
+
idsOf(docs) {
|
|
849
|
+
const ids = [];
|
|
850
|
+
for (const doc of docs) {
|
|
851
|
+
const id = this.idOfDoc(doc);
|
|
852
|
+
if (id !== undefined)
|
|
853
|
+
ids.push(id);
|
|
854
|
+
}
|
|
855
|
+
return ids;
|
|
856
|
+
}
|
|
471
857
|
/** The items of the partition, in the order of the entries. */
|
|
472
858
|
async liveDocs(extra) {
|
|
473
859
|
const { items } = await this.collection();
|
|
474
|
-
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1 }).toArray();
|
|
860
|
+
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
475
861
|
}
|
|
476
862
|
/** The places (`_ord`) of the items of some ids that the partition has. */
|
|
477
863
|
async placesOf(collection, ids) {
|
|
478
864
|
const docs = await collection
|
|
479
865
|
.find(this.liveFilter({ _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } }), { projection: { [KEY]: 1, [ORD]: 1 } })
|
|
480
866
|
.toArray();
|
|
481
|
-
|
|
867
|
+
const places = new Map();
|
|
868
|
+
for (const doc of docs) {
|
|
869
|
+
const id = this.idOfDoc(doc);
|
|
870
|
+
if (id !== undefined)
|
|
871
|
+
places.set(id, this.placeOf(doc));
|
|
872
|
+
}
|
|
873
|
+
return places;
|
|
482
874
|
}
|
|
483
875
|
// ===========================================================================
|
|
484
876
|
// Reads
|
|
@@ -486,18 +878,18 @@ export class MongoDocumentStorage {
|
|
|
486
878
|
async get(id) {
|
|
487
879
|
const { items } = await this.collection();
|
|
488
880
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
489
|
-
return doc ? this.decode(doc)[1] : undefined;
|
|
881
|
+
return doc ? this.decode(doc, id)?.[1] : undefined;
|
|
490
882
|
}
|
|
491
883
|
async getAll() {
|
|
492
|
-
return (await this.liveDocs()).map((
|
|
884
|
+
return this.decodeAll(await this.liveDocs()).map(([, value]) => value);
|
|
493
885
|
}
|
|
494
886
|
async getEntries() {
|
|
495
|
-
return (await this.liveDocs())
|
|
887
|
+
return this.decodeAll(await this.liveDocs());
|
|
496
888
|
}
|
|
497
889
|
async getIds() {
|
|
498
890
|
const { items } = await this.collection();
|
|
499
|
-
const docs = await items.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
|
|
500
|
-
return
|
|
891
|
+
const docs = await items.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
892
|
+
return this.idsOf(docs);
|
|
501
893
|
}
|
|
502
894
|
async find(predicate) {
|
|
503
895
|
return (await this.getAll()).filter(predicate);
|
|
@@ -506,9 +898,9 @@ export class MongoDocumentStorage {
|
|
|
506
898
|
const rule = policy ?? this.options.queryPolicy;
|
|
507
899
|
// The reference checks the selector and the options first, without a read of the database
|
|
508
900
|
queryEntries([], selector, options, rule);
|
|
509
|
-
const filter = selectorPrefilter(selector);
|
|
901
|
+
const filter = selectorPrefilter(selector, { storedId: (id) => this.storedId(id), storageFields: this.reserved });
|
|
510
902
|
const docs = await this.liveDocs(filter ? { $or: [{ [JSON_FIELD]: { $exists: true } }, filter] } : undefined);
|
|
511
|
-
return queryEntries(
|
|
903
|
+
return queryEntries(this.decodeAll(docs), selector, options, rule);
|
|
512
904
|
}
|
|
513
905
|
async has(id) {
|
|
514
906
|
const { items } = await this.collection();
|
|
@@ -516,7 +908,12 @@ export class MongoDocumentStorage {
|
|
|
516
908
|
}
|
|
517
909
|
async size() {
|
|
518
910
|
const { items } = await this.collection();
|
|
519
|
-
|
|
911
|
+
// A document with `_key` tells its id. A document from outside the storage counts only when `idOf` names it, as in getAll
|
|
912
|
+
const [all, outside] = await Promise.all([
|
|
913
|
+
items.countDocuments(this.liveFilter()),
|
|
914
|
+
items.find(this.liveFilter({ [KEY]: { $exists: false } }), { projection: { _id: 1 } }).toArray(),
|
|
915
|
+
]);
|
|
916
|
+
return all - (outside.length - this.idsOf(outside).length);
|
|
520
917
|
}
|
|
521
918
|
async getBatch(ids) {
|
|
522
919
|
const result = new Map();
|
|
@@ -526,7 +923,7 @@ export class MongoDocumentStorage {
|
|
|
526
923
|
}
|
|
527
924
|
const { items } = await this.collection();
|
|
528
925
|
const docs = await items.find(this.liveFilter({ _id: { $in: ids.map((id) => this.storedId(id)) } })).toArray();
|
|
529
|
-
const byId = new Map(
|
|
926
|
+
const byId = new Map(this.decodeAll(docs));
|
|
530
927
|
// The order of the ids of the call
|
|
531
928
|
for (const id of ids)
|
|
532
929
|
if (byId.has(id))
|
|
@@ -554,11 +951,16 @@ export class MongoDocumentStorage {
|
|
|
554
951
|
const results = await items.aggregate(bounded.pipeline, { maxTimeMS }).toArray();
|
|
555
952
|
return cutResults(results, maxResults, bounded.limited);
|
|
556
953
|
}
|
|
557
|
-
/**
|
|
954
|
+
/**
|
|
955
|
+
* The version of an item: the sequence number of its last write, or undefined for a missing id.
|
|
956
|
+
* An item without a write through the storage (no `_seq`) has the version `0`. It is the version
|
|
957
|
+
* of `versionOfStored()` of the stored document (roadmap CLI-77): the two share one rule.
|
|
958
|
+
*/
|
|
558
959
|
async versionOf(id) {
|
|
559
960
|
const { items } = await this.collection();
|
|
560
961
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { [SEQ]: 1 } });
|
|
561
|
-
|
|
962
|
+
// The filter finds the document by the stored `_id` of the id in the partition: it is the item
|
|
963
|
+
return doc ? seqVersion(doc) : undefined;
|
|
562
964
|
}
|
|
563
965
|
async getChangesSince(since) {
|
|
564
966
|
const { db, items } = await this.collection();
|
|
@@ -568,7 +970,7 @@ export class MongoDocumentStorage {
|
|
|
568
970
|
const full = async () => ({
|
|
569
971
|
version,
|
|
570
972
|
full: true,
|
|
571
|
-
changed: (await items.find(this.liveFilter()).sort({ [ORD]: 1 }).toArray())
|
|
973
|
+
changed: this.decodeAll(await items.find(this.liveFilter()).sort({ [ORD]: 1, _id: 1 }).toArray()),
|
|
572
974
|
deleted: [],
|
|
573
975
|
});
|
|
574
976
|
const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
|
|
@@ -587,10 +989,13 @@ export class MongoDocumentStorage {
|
|
|
587
989
|
const changed = [];
|
|
588
990
|
const deleted = [];
|
|
589
991
|
for (const doc of docs) {
|
|
992
|
+
const id = this.idOfDoc(doc);
|
|
993
|
+
if (id === undefined)
|
|
994
|
+
continue;
|
|
590
995
|
if (doc[TOMB] === true)
|
|
591
|
-
deleted.push(
|
|
996
|
+
deleted.push(id);
|
|
592
997
|
else
|
|
593
|
-
changed.push(this.decode(doc));
|
|
998
|
+
changed.push(this.decode(doc, id));
|
|
594
999
|
}
|
|
595
1000
|
return { version, full: false, changed, deleted };
|
|
596
1001
|
});
|
|
@@ -600,7 +1005,11 @@ export class MongoDocumentStorage {
|
|
|
600
1005
|
// ===========================================================================
|
|
601
1006
|
async write(task) {
|
|
602
1007
|
const { db, items } = await this.collection();
|
|
603
|
-
return enqueue(db, this.collectionName, () =>
|
|
1008
|
+
return enqueue(db, this.collectionName, async () => {
|
|
1009
|
+
// The floor document of the partition, outside each transaction of the task (roadmap O66)
|
|
1010
|
+
await this.noteFloor(db);
|
|
1011
|
+
return task(db, items);
|
|
1012
|
+
});
|
|
604
1013
|
}
|
|
605
1014
|
async set(id, value, options) {
|
|
606
1015
|
await this.setBatch([[id, value]], options?.expectedVersion === undefined ? undefined : { expectedVersions: [[id, options.expectedVersion]] });
|
|
@@ -618,6 +1027,7 @@ export class MongoDocumentStorage {
|
|
|
618
1027
|
// The ids and the encoding are checked before a number of the sequence goes
|
|
619
1028
|
for (const [id, value] of items) {
|
|
620
1029
|
this.storedId(id);
|
|
1030
|
+
this.flatOf(id, value);
|
|
621
1031
|
JSON.stringify(value);
|
|
622
1032
|
}
|
|
623
1033
|
await this.write(async (db, collection) => {
|
|
@@ -651,8 +1061,10 @@ export class MongoDocumentStorage {
|
|
|
651
1061
|
const doc = await collection.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
652
1062
|
if (!doc)
|
|
653
1063
|
return undefined;
|
|
654
|
-
const next = applyPatch(this.decode(doc)[1], fields);
|
|
655
|
-
|
|
1064
|
+
const next = applyPatch(this.decode(doc, id)[1], fields);
|
|
1065
|
+
// The encoding is checked before a number of the sequence goes
|
|
1066
|
+
this.flatOf(id, next);
|
|
1067
|
+
await this.withNumbers(db, collection, 1, (first, session) => collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) }));
|
|
656
1068
|
return next;
|
|
657
1069
|
});
|
|
658
1070
|
}
|
|
@@ -687,7 +1099,7 @@ export class MongoDocumentStorage {
|
|
|
687
1099
|
.toArray();
|
|
688
1100
|
if (live.length === 0)
|
|
689
1101
|
return 0;
|
|
690
|
-
const liveIds = new Set(
|
|
1102
|
+
const liveIds = new Set(this.idsOf(live));
|
|
691
1103
|
const deleted = unique.filter((id) => liveIds.has(id));
|
|
692
1104
|
await this.tombstone(db, collection, deleted);
|
|
693
1105
|
return deleted.length;
|
|
@@ -695,10 +1107,11 @@ export class MongoDocumentStorage {
|
|
|
695
1107
|
}
|
|
696
1108
|
async clear(_options) {
|
|
697
1109
|
await this.write(async (db, collection) => {
|
|
698
|
-
const live = await collection.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
|
|
699
|
-
|
|
1110
|
+
const live = await collection.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
1111
|
+
const ids = this.idsOf(live);
|
|
1112
|
+
if (ids.length === 0)
|
|
700
1113
|
return;
|
|
701
|
-
await this.tombstone(db, collection,
|
|
1114
|
+
await this.tombstone(db, collection, ids);
|
|
702
1115
|
});
|
|
703
1116
|
}
|
|
704
1117
|
/**
|
|
@@ -710,8 +1123,7 @@ export class MongoDocumentStorage {
|
|
|
710
1123
|
const guards = await this.checkVersions(collection, expected, session);
|
|
711
1124
|
const operations = ids.map((id, index) => {
|
|
712
1125
|
const tomb = { _id: this.storedId(id), [KEY]: id, [SEQ]: first + index, [TOMB]: true };
|
|
713
|
-
|
|
714
|
-
tomb[PART] = this.partition;
|
|
1126
|
+
this.putPartition(tomb);
|
|
715
1127
|
return { replaceOne: { filter: { ...this.writeFilter(id), ...guards.get(id) }, replacement: tomb } };
|
|
716
1128
|
});
|
|
717
1129
|
const result = await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
|
|
@@ -721,6 +1133,215 @@ export class MongoDocumentStorage {
|
|
|
721
1133
|
});
|
|
722
1134
|
await this.pruneTombstones(db, collection);
|
|
723
1135
|
}
|
|
1136
|
+
// ===========================================================================
|
|
1137
|
+
// The raw writes (D39, roadmap CLI-77)
|
|
1138
|
+
// ===========================================================================
|
|
1139
|
+
/**
|
|
1140
|
+
* This method adopts a raw write: one conditional write through the path of each write gives the
|
|
1141
|
+
* document a new `_seq` (see `RawStorageWrite`). It is for `watchStorageChanges()`, and it is no
|
|
1142
|
+
* general setter of `_seq`.
|
|
1143
|
+
*/
|
|
1144
|
+
async adoptRaw(raw) {
|
|
1145
|
+
const [result] = await this.adoptRawAll([raw]);
|
|
1146
|
+
if (result === undefined || typeof result !== "string")
|
|
1147
|
+
throw result?.error;
|
|
1148
|
+
return result;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* This method adopts several raw writes. The writes of each partition take one block of numbers
|
|
1152
|
+
* through the path of each write. A number of a write that does not win stays unused.
|
|
1153
|
+
*/
|
|
1154
|
+
async adoptRawAll(raws) {
|
|
1155
|
+
const results = raws.map(() => "unknown");
|
|
1156
|
+
const groups = new Map();
|
|
1157
|
+
raws.forEach((raw, index) => {
|
|
1158
|
+
const target = this.adoptionTarget(raw);
|
|
1159
|
+
if (!target)
|
|
1160
|
+
return;
|
|
1161
|
+
let group = groups.get(target.storage);
|
|
1162
|
+
if (!group)
|
|
1163
|
+
groups.set(target.storage, (group = []));
|
|
1164
|
+
group.push({ index, raw, id: target.id });
|
|
1165
|
+
});
|
|
1166
|
+
for (const [storage, group] of groups) {
|
|
1167
|
+
try {
|
|
1168
|
+
const done = await storage.adoptGroup(group);
|
|
1169
|
+
group.forEach((entry, position) => (results[entry.index] = done[position] ?? "lost"));
|
|
1170
|
+
}
|
|
1171
|
+
catch (error) {
|
|
1172
|
+
for (const entry of group)
|
|
1173
|
+
results[entry.index] = { error };
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return results;
|
|
1177
|
+
}
|
|
1178
|
+
/** The adoption of raw writes of this partition, with one block of numbers. */
|
|
1179
|
+
async adoptGroup(group) {
|
|
1180
|
+
return this.write(async (db, collection) => {
|
|
1181
|
+
const results = await this.withNumbers(db, collection, group.length, async (first, session) => {
|
|
1182
|
+
const out = [];
|
|
1183
|
+
for (const [position, { raw, id }] of group.entries()) {
|
|
1184
|
+
const seq = first + position;
|
|
1185
|
+
out.push(raw.after === undefined ? await this.adoptDelete(collection, raw, id, seq, session) : await this.adoptWrite(collection, raw, raw.after, id, seq, session));
|
|
1186
|
+
}
|
|
1187
|
+
return out;
|
|
1188
|
+
}, false);
|
|
1189
|
+
if (results.some((result, position) => result === "adopted" && group[position].raw.after === undefined))
|
|
1190
|
+
await this.pruneTombstones(db, collection);
|
|
1191
|
+
return results;
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/** A raw hard delete: a tombstone with the id and the partition of the image before the change. */
|
|
1195
|
+
async adoptDelete(collection, raw, id, seq, session) {
|
|
1196
|
+
// In a transaction, a duplicate key would end the transaction: the read comes first
|
|
1197
|
+
if (session && (await collection.findOne({ _id: raw.storedId }, { projection: { _id: 1 }, session })))
|
|
1198
|
+
return "lost";
|
|
1199
|
+
const tomb = { _id: raw.storedId, [KEY]: id, [SEQ]: seq, [TOMB]: true };
|
|
1200
|
+
this.putPartition(tomb);
|
|
1201
|
+
try {
|
|
1202
|
+
await collection.insertOne(tomb, session ? { session } : {});
|
|
1203
|
+
return "adopted";
|
|
1204
|
+
}
|
|
1205
|
+
catch (error) {
|
|
1206
|
+
// A raw insert of the same _id came after the delete: that insert is the newer state
|
|
1207
|
+
if (isDuplicateKey(error))
|
|
1208
|
+
return "lost";
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/** A raw insert, replace or update: one conditional write on the exact state of the event. */
|
|
1213
|
+
async adoptWrite(collection, raw, after, id, seq, session) {
|
|
1214
|
+
const fields = this.restoredFields(raw, after, id);
|
|
1215
|
+
// The exact state of the event: the same `_seq` (or none), and no field that the adoption sets
|
|
1216
|
+
const filter = { _id: raw.storedId, [SEQ]: raw.seq ?? { $exists: false } };
|
|
1217
|
+
for (const name of Object.keys(fields))
|
|
1218
|
+
filter[name] = { $exists: false };
|
|
1219
|
+
const result = await collection.updateOne(filter, { $set: { ...fields, [SEQ]: seq } }, session ? { session } : {});
|
|
1220
|
+
return result.matchedCount === 1 ? "adopted" : "lost";
|
|
1221
|
+
}
|
|
1222
|
+
/** One page of the documents without `_seq`, in the order of `_id` (the sweep of `watchStorageChanges()`). */
|
|
1223
|
+
async rawDocuments(after, limit) {
|
|
1224
|
+
const db = await this.database();
|
|
1225
|
+
// The scan walks the index of _id from the last _id: `min` bounds the scan, in the order of the
|
|
1226
|
+
// BSON types. `min` includes that _id, so the page skips it. No index can find the documents
|
|
1227
|
+
// without a field (a partial index cannot express $exists: false): the scan reads each document.
|
|
1228
|
+
let cursor = db.collection(this.collectionName).find({ [SEQ]: { $exists: false } }).hint({ _id: 1 });
|
|
1229
|
+
if (after)
|
|
1230
|
+
cursor = cursor.min({ _id: after.id });
|
|
1231
|
+
const docs = await cursor.limit(after ? limit + 1 : limit).toArray();
|
|
1232
|
+
if (after && docs.length > 0 && sameBson(docs[0]["_id"], after.id))
|
|
1233
|
+
docs.shift();
|
|
1234
|
+
return docs.slice(0, limit);
|
|
1235
|
+
}
|
|
1236
|
+
/** The stored documents of these stored `_id`s, for the adoptions that `watchStorageChanges()` does again. */
|
|
1237
|
+
async storedDocuments(ids) {
|
|
1238
|
+
const db = await this.database();
|
|
1239
|
+
const filter = { _id: { $in: [...ids] } };
|
|
1240
|
+
return db.collection(this.collectionName).find(filter).toArray();
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* The storage of the partition of a raw write and the id of its document, or undefined when
|
|
1244
|
+
* neither the documents of the event nor the stored `_id` tell them. The partition comes from
|
|
1245
|
+
* `form.partitionOf()` of the image before the change, else of the document after the write,
|
|
1246
|
+
* else of the stored `_id` (the order of `storageChangeOf()`). The document must not have a
|
|
1247
|
+
* partition field with another value.
|
|
1248
|
+
*/
|
|
1249
|
+
adoptionTarget(raw) {
|
|
1250
|
+
const doc = raw.after ?? raw.before;
|
|
1251
|
+
if (!doc)
|
|
1252
|
+
return undefined;
|
|
1253
|
+
const places = new Set();
|
|
1254
|
+
for (const source of [raw.before, raw.after, { _id: raw.storedId }])
|
|
1255
|
+
if (isPlainObject(source))
|
|
1256
|
+
places.add(this.form.partitionOf(source));
|
|
1257
|
+
for (const place of places) {
|
|
1258
|
+
if (place !== this.partition && (this.partition !== undefined || place === undefined))
|
|
1259
|
+
continue;
|
|
1260
|
+
let storage;
|
|
1261
|
+
try {
|
|
1262
|
+
storage = place === this.partition ? this : this.forPartition(place);
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (!storage.holds(doc))
|
|
1268
|
+
continue;
|
|
1269
|
+
const fromId = this.form.idOf(raw.storedId, place);
|
|
1270
|
+
const key = raw.after?.[KEY] ?? raw.before?.[KEY];
|
|
1271
|
+
const id = typeof fromId === "string" ? fromId : typeof key === "string" ? key : undefined;
|
|
1272
|
+
if (id === undefined || id.includes(SEPARATOR))
|
|
1273
|
+
continue;
|
|
1274
|
+
return { storage, id };
|
|
1275
|
+
}
|
|
1276
|
+
return undefined;
|
|
1277
|
+
}
|
|
1278
|
+
/** True when each partition field of a document has the value of this partition, or is absent (a raw replace can drop it). */
|
|
1279
|
+
holds(doc) {
|
|
1280
|
+
for (const [name, value] of this.partitionEntries) {
|
|
1281
|
+
if (!(name in doc))
|
|
1282
|
+
continue;
|
|
1283
|
+
if (value === undefined || !isDeepStrictEqual(doc[name], value))
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* The storage fields and the partition fields that a raw write took from the document, for the
|
|
1290
|
+
* adoption to set again. No field of the item is in it.
|
|
1291
|
+
*
|
|
1292
|
+
* - The partition fields of this partition that the document does not have.
|
|
1293
|
+
* - `_key` (with `_kid`) of the image before the change. Without the image, `_key` only when
|
|
1294
|
+
* `form.idOf()` does not tell the id, with `_kid`: the item keeps its `_id`, as a document from
|
|
1295
|
+
* outside the storage.
|
|
1296
|
+
* - `_ord` of the image before the change, else `0`, the place of a document without `_ord`
|
|
1297
|
+
* (the rule of the old documents).
|
|
1298
|
+
* - The names of `_kpf` of the image before the change that the document has with the value of
|
|
1299
|
+
* the partition.
|
|
1300
|
+
*/
|
|
1301
|
+
restoredFields(raw, after, id) {
|
|
1302
|
+
const before = raw.before;
|
|
1303
|
+
const fields = {};
|
|
1304
|
+
for (const [name, value] of this.partitionEntries)
|
|
1305
|
+
if (value !== undefined && !(name in after))
|
|
1306
|
+
fields[name] = value;
|
|
1307
|
+
if (after[KEY] === undefined) {
|
|
1308
|
+
if (typeof before?.[KEY] === "string") {
|
|
1309
|
+
fields[KEY] = before[KEY];
|
|
1310
|
+
if (before[KEEP_ID] === true && after[KEEP_ID] === undefined)
|
|
1311
|
+
fields[KEEP_ID] = true;
|
|
1312
|
+
}
|
|
1313
|
+
else if (this.form.idOf(raw.storedId, this.partition) !== id) {
|
|
1314
|
+
fields[KEY] = id;
|
|
1315
|
+
if (after[KEEP_ID] === undefined)
|
|
1316
|
+
fields[KEEP_ID] = true;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (after[ORD] === undefined)
|
|
1320
|
+
fields[ORD] = typeof before?.[ORD] === "number" ? before[ORD] : 0;
|
|
1321
|
+
const kept = before?.[KEEP_PART];
|
|
1322
|
+
if (after[KEEP_PART] === undefined && Array.isArray(kept)) {
|
|
1323
|
+
const names = kept.filter((name) => typeof name === "string" && this.partitionValues.has(name) && isDeepStrictEqual(after[name], this.partitionValues.get(name)));
|
|
1324
|
+
if (names.length > 0)
|
|
1325
|
+
fields[KEEP_PART] = names;
|
|
1326
|
+
}
|
|
1327
|
+
return fields;
|
|
1328
|
+
}
|
|
1329
|
+
/** 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()`. */
|
|
1330
|
+
async knownPartitions() {
|
|
1331
|
+
const db = await this.database();
|
|
1332
|
+
const prefix = `floor${SEPARATOR}${this.collectionName}${SEPARATOR}`;
|
|
1333
|
+
const floors = await db
|
|
1334
|
+
.collection(this.countersName)
|
|
1335
|
+
.find({ _id: { $regex: `^${escapeRegex(prefix)}` } }, { projection: { _id: 1 } })
|
|
1336
|
+
.limit(MAX_KNOWN_PARTITIONS)
|
|
1337
|
+
.toArray();
|
|
1338
|
+
const found = new Set(this.partitions.keys());
|
|
1339
|
+
for (const floor of floors) {
|
|
1340
|
+
const rest = String(floor._id).slice(prefix.length);
|
|
1341
|
+
found.add(rest === "" ? undefined : rest);
|
|
1342
|
+
}
|
|
1343
|
+
return [...found];
|
|
1344
|
+
}
|
|
724
1345
|
/**
|
|
725
1346
|
* The preconditions of a write (roadmap CLI-16), after a check of their ids. A storage without
|
|
726
1347
|
* `versionOf` throws a `TypeError` before the write starts.
|
|
@@ -739,11 +1360,18 @@ export class MongoDocumentStorage {
|
|
|
739
1360
|
const docs = await collection
|
|
740
1361
|
.find({ ...this.partitionFilter(), _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } }, session ? { session } : {})
|
|
741
1362
|
.toArray();
|
|
742
|
-
|
|
1363
|
+
const byId = new Map();
|
|
1364
|
+
for (const doc of docs) {
|
|
1365
|
+
const id = this.idOfDoc(doc);
|
|
1366
|
+
if (id !== undefined)
|
|
1367
|
+
byId.set(id, doc);
|
|
1368
|
+
}
|
|
1369
|
+
return byId;
|
|
743
1370
|
}
|
|
744
1371
|
/** The current item of a stored document: no item for a tombstone or a missing document. */
|
|
745
1372
|
currentOf(doc) {
|
|
746
|
-
|
|
1373
|
+
const entry = doc && doc[TOMB] !== true ? this.decode(doc) : undefined;
|
|
1374
|
+
return entry ? { found: true, value: entry[1] } : { found: false };
|
|
747
1375
|
}
|
|
748
1376
|
/**
|
|
749
1377
|
* This method tests the expected versions (roadmap CLI-16). A conflict throws
|
|
@@ -768,7 +1396,8 @@ export class MongoDocumentStorage {
|
|
|
768
1396
|
return guards;
|
|
769
1397
|
for (const [id] of expected) {
|
|
770
1398
|
const doc = docs.get(id);
|
|
771
|
-
|
|
1399
|
+
// A document without `_seq` (not written through the storage yet) keeps that state until the write
|
|
1400
|
+
guards.set(id, doc && typeof doc[SEQ] === "number" ? { [SEQ]: doc[SEQ] } : { [SEQ]: { $exists: false } });
|
|
772
1401
|
}
|
|
773
1402
|
return guards;
|
|
774
1403
|
}
|