@procedures/mongo 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +34 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storage/document-storage.d.ts +177 -8
- package/dist/storage/document-storage.d.ts.map +1 -1
- package/dist/storage/document-storage.js +383 -72
- package/dist/storage/document-storage.js.map +1 -1
- package/dist/storage/index.d.ts +3 -3
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +2 -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 +37 -15
- package/dist/storage/materializer.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +11 -3
- package/src/storage/document-storage.ts +441 -71
- package/src/storage/index.ts +11 -3
- package/src/storage/materializer.ts +54 -22
|
@@ -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,9 +85,14 @@
|
|
|
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
|
|
|
50
94
|
import { randomUUID } from "node:crypto";
|
|
95
|
+
import { isDeepStrictEqual } from "node:util";
|
|
51
96
|
import type { AnyBulkWriteOperation, ClientSession, Collection, Db, Document, Filter, MongoClient } from "mongodb";
|
|
52
97
|
import {
|
|
53
98
|
VersionConflictError,
|
|
@@ -82,10 +127,81 @@ const TOMB = "_tomb";
|
|
|
82
127
|
const JSON_FIELD = "_json";
|
|
83
128
|
/** True in a stored document whose item had `_id` equal to its id. */
|
|
84
129
|
const KEEP_ID = "_kid";
|
|
85
|
-
|
|
130
|
+
/** The partition fields that the item had with the value of its partition (roadmap CLI-76). The write leaves them out of the fields. */
|
|
131
|
+
const KEEP_PART = "_kpf";
|
|
132
|
+
const STORAGE_FIELDS: ReadonlySet<string> = new Set(["_id", KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
86
133
|
/** The separator of the partition and the id in a stored `_id`. */
|
|
87
134
|
export const SEPARATOR = "\u001f";
|
|
88
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The fields that a `MongoDocumentStorage` can add to a stored document, in each stored form
|
|
138
|
+
* (roadmap CLI-76). A reader of the raw documents can leave them out. The partition fields are
|
|
139
|
+
* not in the list.
|
|
140
|
+
*/
|
|
141
|
+
export const DOCUMENT_STORAGE_FIELDS: readonly string[] = Object.freeze([KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
142
|
+
|
|
143
|
+
/** The field that is `true` in a tombstone: the document of a deleted item. A reader of the raw documents leaves them out. */
|
|
144
|
+
export const TOMBSTONE_FIELD: "_tomb" = TOMB;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The stored form of a `MongoDocumentStorage` (roadmap CLI-76): how an id and a partition become a
|
|
148
|
+
* stored document, and back. Give the same object to the storage (`form`) and to a
|
|
149
|
+
* `ViewMaterializer` (`storageSources`), or read it from `storage.form`.
|
|
150
|
+
*/
|
|
151
|
+
export interface StoredForm {
|
|
152
|
+
/**
|
|
153
|
+
* The stored `_id` of an id in a partition. It must give a value other than `undefined` and
|
|
154
|
+
* `null`, and two ids of one partition must not give the same value.
|
|
155
|
+
*/
|
|
156
|
+
storedId(partition: string | undefined, id: string): unknown;
|
|
157
|
+
/** The inverse of `storedId`: the id of a stored `_id`, or `undefined` when the `_id` is not of the partition. */
|
|
158
|
+
idOf(storedId: unknown, partition: string | undefined): string | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* The fields that name a partition. Each document of the partition has them, and they are the
|
|
161
|
+
* filter of the partition. A field with the value `undefined` is absent: the document does not
|
|
162
|
+
* have it, and the filter is `{ $exists: false }`. Give the same names for each partition. A
|
|
163
|
+
* top-level field of an item with one of these names and the value of the partition is left out
|
|
164
|
+
* of the stored fields and noted in `_kpf`, and the read gives it back. Another value is refused
|
|
165
|
+
* with a `TypeError`.
|
|
166
|
+
*/
|
|
167
|
+
partitionFields(partition: string | undefined): Readonly<Record<string, unknown>>;
|
|
168
|
+
/**
|
|
169
|
+
* The partition of a stored document, or `undefined` when the document does not tell it. A
|
|
170
|
+
* `ViewMaterializer` gives the document of a change event: `fullDocument`, else
|
|
171
|
+
* `fullDocumentBeforeChange`, else `documentKey`. A delete event has only `documentKey`, so with
|
|
172
|
+
* another `storedId`, the `_id` must tell the partition, or the collection needs
|
|
173
|
+
* `changeStreamPreAndPostImages`. The storage writes a delete as a tombstone (a replace event with
|
|
174
|
+
* the whole document), so the gap is only for a raw hard delete.
|
|
175
|
+
*/
|
|
176
|
+
partitionOf(doc: Document): string | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The default stored form, the form of 0.34.0. The stored `_id` is the id, or `<partition>` +
|
|
181
|
+
* U+001F + `<id>` with a partition. The partition field is `_part` (absent without a partition).
|
|
182
|
+
* `partitionOf` reads `_part`, else the part of `_id` before U+001F.
|
|
183
|
+
*/
|
|
184
|
+
export const DEFAULT_STORED_FORM: StoredForm = Object.freeze({
|
|
185
|
+
storedId(partition: string | undefined, id: string): string {
|
|
186
|
+
return partition === undefined ? id : `${partition}${SEPARATOR}${id}`;
|
|
187
|
+
},
|
|
188
|
+
idOf(storedId: unknown, partition: string | undefined): string | undefined {
|
|
189
|
+
if (typeof storedId !== "string") return undefined;
|
|
190
|
+
if (partition === undefined) return storedId.includes(SEPARATOR) ? undefined : storedId;
|
|
191
|
+
const prefix = `${partition}${SEPARATOR}`;
|
|
192
|
+
return storedId.startsWith(prefix) ? storedId.slice(prefix.length) : undefined;
|
|
193
|
+
},
|
|
194
|
+
partitionFields(partition: string | undefined): Record<string, unknown> {
|
|
195
|
+
return { [PART]: partition };
|
|
196
|
+
},
|
|
197
|
+
partitionOf(doc: Document): string | undefined {
|
|
198
|
+
if (typeof doc[PART] === "string") return doc[PART];
|
|
199
|
+
const id: unknown = doc["_id"];
|
|
200
|
+
const at = typeof id === "string" ? id.indexOf(SEPARATOR) : -1;
|
|
201
|
+
return at > 0 ? (id as string).slice(0, at) : undefined;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
89
205
|
/** The options of a `MongoDocumentStorage`. */
|
|
90
206
|
export interface MongoDocumentStorageOptions<T = unknown> {
|
|
91
207
|
/** The MongoDB collection. */
|
|
@@ -107,7 +223,8 @@ export interface MongoDocumentStorageOptions<T = unknown> {
|
|
|
107
223
|
writeLease?: Duration | undefined;
|
|
108
224
|
/**
|
|
109
225
|
* The client of the database, for the transactions. The default is the client of the default
|
|
110
|
-
* connection when `db` is absent. Without a client, the writes use the watermark
|
|
226
|
+
* connection when `db` is absent. Without a client, the writes use the watermark: with `db` and
|
|
227
|
+
* no `client`, a write is not a transaction, also on a replica set.
|
|
111
228
|
*/
|
|
112
229
|
client?: (() => MongoClient | Promise<MongoClient>) | undefined;
|
|
113
230
|
/**
|
|
@@ -123,6 +240,11 @@ export interface MongoDocumentStorageOptions<T = unknown> {
|
|
|
123
240
|
* the sequence number of the last write.
|
|
124
241
|
*/
|
|
125
242
|
versionOf?: ((item: T) => string | undefined) | undefined;
|
|
243
|
+
/**
|
|
244
|
+
* The stored form (roadmap CLI-76): the stored `_id`, its inverse, the partition fields and the
|
|
245
|
+
* partition of a document. The default `DEFAULT_STORED_FORM` writes the documents of 0.34.0.
|
|
246
|
+
*/
|
|
247
|
+
form?: StoredForm | undefined;
|
|
126
248
|
}
|
|
127
249
|
|
|
128
250
|
/** True for the error of a duplicate key: an insert of an upsert whose filter found no document. */
|
|
@@ -132,8 +254,8 @@ function isDuplicateKey(error: unknown): boolean {
|
|
|
132
254
|
return code === 11000 || [writeErrors].flat().some((entry) => (entry as { code?: unknown } | null)?.code === 11000);
|
|
133
255
|
}
|
|
134
256
|
|
|
135
|
-
/** A stored document. */
|
|
136
|
-
type Stored = Document
|
|
257
|
+
/** A stored document. Its `_id` comes from `storedId`: a text by default, any value but `undefined` and `null` in another form. */
|
|
258
|
+
type Stored = Document;
|
|
137
259
|
|
|
138
260
|
/** A range of numbers that a write took and has not written yet. */
|
|
139
261
|
interface PendingRange {
|
|
@@ -190,20 +312,113 @@ function enqueue<R>(db: Db, collection: string, task: () => Promise<R>): Promise
|
|
|
190
312
|
return run;
|
|
191
313
|
}
|
|
192
314
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
315
|
+
/**
|
|
316
|
+
* True for a value of a field that BSON gives back as it is: a key with `$` or a dot stays as it is
|
|
317
|
+
* (MongoDB 5.0 or later). False when only JSON text keeps it: a class instance other than `Date`,
|
|
318
|
+
* or a key `__proto__`. An object that BSON gives back as a `DBRef` adds a problem.
|
|
319
|
+
*/
|
|
320
|
+
function isNativeValue(value: unknown, path: string, problems: string[]): boolean {
|
|
321
|
+
if (Array.isArray(value)) return value.every((element, index) => isNativeValue(element, `${path}.${index}`, problems));
|
|
196
322
|
if (typeof value !== "object" || value === null) return true;
|
|
197
323
|
const proto = Object.getPrototypeOf(value) as unknown;
|
|
198
324
|
if (proto !== Object.prototype && proto !== null) return value instanceof Date;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
325
|
+
const object = value as Record<string, unknown>;
|
|
326
|
+
// The test of the BSON reader: a text `$ref`, an `$id`, and no `$db` or a text `$db`
|
|
327
|
+
if (typeof object["$ref"] === "string" && object["$id"] !== undefined && object["$id"] !== null && (object["$db"] === undefined || typeof object["$db"] === "string")) {
|
|
328
|
+
problems.push(`the field ${path} has $ref and $id, and MongoDB gives it back as a DBRef`);
|
|
329
|
+
}
|
|
330
|
+
for (const [key, field] of Object.entries(object)) {
|
|
331
|
+
if (key === "__proto__") return false;
|
|
332
|
+
if (!isNativeValue(field, `${path}.${key}`, problems)) return false;
|
|
203
333
|
}
|
|
204
334
|
return true;
|
|
205
335
|
}
|
|
206
336
|
|
|
337
|
+
/** The fields of the flat document of an item. */
|
|
338
|
+
interface FlatItem {
|
|
339
|
+
/** True when the item has `_id`, equal to its id. */
|
|
340
|
+
keepsId: boolean;
|
|
341
|
+
/** The partition fields that the item has with the value of its partition: the write leaves them out. */
|
|
342
|
+
kept: string[];
|
|
343
|
+
fields: Array<[string, unknown]>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The item of a stored document (not a tombstone) whose id is known. `_json` gives the item as it
|
|
348
|
+
* was. Else the fields of the storage stay out, the item gets `_id` when it had it (`_kid`), when
|
|
349
|
+
* the document is from outside the storage (no `_key`) or with `addId`, and it gets back the
|
|
350
|
+
* partition fields of `_kpf`. A partition field with the value of the partition stays out (for a
|
|
351
|
+
* document from outside the storage too); a field with another value stays in.
|
|
352
|
+
*/
|
|
353
|
+
function itemOf(doc: Document, id: string, partitionEntries: ReadonlyArray<readonly [string, unknown]>, addId: boolean): unknown {
|
|
354
|
+
if (typeof doc[JSON_FIELD] === "string") {
|
|
355
|
+
const item = JSON.parse(doc[JSON_FIELD]) as unknown;
|
|
356
|
+
if (addId && isPlainObject(item) && !("_id" in item)) return { _id: id, ...item };
|
|
357
|
+
return item;
|
|
358
|
+
}
|
|
359
|
+
const outside = doc[KEY] === undefined;
|
|
360
|
+
const value: Record<string, unknown> = {};
|
|
361
|
+
if (addId || outside || doc[KEEP_ID] === true) value["_id"] = id;
|
|
362
|
+
const partitionValues = new Map(partitionEntries);
|
|
363
|
+
for (const [key, field] of Object.entries(doc)) {
|
|
364
|
+
if (STORAGE_FIELDS.has(key)) continue;
|
|
365
|
+
// The value of the partition comes back only through `_kpf`, so the item has one form at each age of its document
|
|
366
|
+
if (partitionValues.has(key) && isDeepStrictEqual(field, partitionValues.get(key))) continue;
|
|
367
|
+
put(value, key, field);
|
|
368
|
+
}
|
|
369
|
+
const kept = doc[KEEP_PART];
|
|
370
|
+
if (Array.isArray(kept)) {
|
|
371
|
+
for (const [name, field] of partitionEntries) if (kept.includes(name) && !STORAGE_FIELDS.has(name)) put(value, name, field);
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The options of `decodeStoredDocument`. */
|
|
377
|
+
export interface DecodeStoredDocumentOptions {
|
|
378
|
+
/** The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`). */
|
|
379
|
+
form?: StoredForm | undefined;
|
|
380
|
+
/** The partition of the document. The default is `form.partitionOf(doc)`. */
|
|
381
|
+
partition?: string | undefined;
|
|
382
|
+
/**
|
|
383
|
+
* True (the default): an item that is a plain object without `_id` gets its id as `_id`. False:
|
|
384
|
+
* the item is as the storage gives it, with `_id` only when it had it.
|
|
385
|
+
*/
|
|
386
|
+
addId?: boolean | undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** An item of a stored document, from `decodeStoredDocument`. */
|
|
390
|
+
export interface StoredDocumentEntry<T> {
|
|
391
|
+
/** The id of the item. */
|
|
392
|
+
id: string;
|
|
393
|
+
/** The partition of the document. */
|
|
394
|
+
partition: string | undefined;
|
|
395
|
+
/** The item. */
|
|
396
|
+
item: T;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* This function reads a raw document of a `MongoDocumentStorage` collection as the storage reads
|
|
401
|
+
* it (roadmap CLI-76), for an app that reads the collection directly (a query, a view, an
|
|
402
|
+
* aggregation). It gives `undefined` for a tombstone and for a document whose id neither `idOf`
|
|
403
|
+
* nor `_key` tells. Else it parses `_json`, leaves out the fields of the storage
|
|
404
|
+
* (`DOCUMENT_STORAGE_FIELDS`) and gives the item its id as `_id` (see `addId`).
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```typescript
|
|
408
|
+
* const items = docs.flatMap((doc) => decodeStoredDocument<Note>(doc, { form: notes.form })?.item ?? []);
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
export function decodeStoredDocument<T = Document>(doc: Document, options: DecodeStoredDocumentOptions = {}): StoredDocumentEntry<T> | undefined {
|
|
412
|
+
if (!isPlainObject(doc) || doc[TOMB] === true) return undefined;
|
|
413
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
414
|
+
const partition = options.partition ?? form.partitionOf(doc);
|
|
415
|
+
const fromId = form.idOf(doc["_id"], partition);
|
|
416
|
+
const id = typeof fromId === "string" ? fromId : typeof doc[KEY] === "string" ? doc[KEY] : undefined;
|
|
417
|
+
if (id === undefined) return undefined;
|
|
418
|
+
const entries = Object.entries(form.partitionFields(partition));
|
|
419
|
+
return { id, partition, item: itemOf(doc, id, entries, options.addId ?? true) as T };
|
|
420
|
+
}
|
|
421
|
+
|
|
207
422
|
/** True for a plain object. */
|
|
208
423
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
209
424
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
@@ -221,17 +436,52 @@ function isScalar(value: unknown): value is string | number | boolean {
|
|
|
221
436
|
return typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
222
437
|
}
|
|
223
438
|
|
|
439
|
+
/** The options of `selectorPrefilter`. */
|
|
440
|
+
export interface SelectorPrefilterOptions {
|
|
441
|
+
/**
|
|
442
|
+
* The stored `_id` of an id. With it, a condition on `_id` with text values tests the stored
|
|
443
|
+
* `_id` (and uses its index). Without it, the condition tests `_key`.
|
|
444
|
+
*/
|
|
445
|
+
storedId?: ((id: string) => unknown) | undefined;
|
|
446
|
+
/** More fields that only the storage writes, for example the partition fields: a condition on one of them does not go in the filter. */
|
|
447
|
+
storageFields?: Iterable<string> | undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
224
450
|
/** The field of a stored document for a path of a selector, or undefined when the filter cannot test it. */
|
|
225
|
-
function storedPath(path: string): string | undefined {
|
|
451
|
+
function storedPath(path: string, reserved: ReadonlySet<string>): string | undefined {
|
|
226
452
|
if (path === "_id") return KEY;
|
|
227
|
-
const
|
|
228
|
-
|
|
453
|
+
const parts = path.split(".");
|
|
454
|
+
const first = parts[0]!;
|
|
455
|
+
if (first === "_id" || STORAGE_FIELDS.has(first) || reserved.has(first)) return undefined;
|
|
456
|
+
// A part with `$` is a field name of an item, but an operator for MongoDB
|
|
457
|
+
if (parts.some((part) => part.startsWith("$"))) return undefined;
|
|
229
458
|
return path;
|
|
230
459
|
}
|
|
231
460
|
|
|
461
|
+
/**
|
|
462
|
+
* The filter of a condition on `_id` against the stored `_id`, or undefined. A flat item with an
|
|
463
|
+
* `_id` has its id as `_id`, and an item stored as JSON passes the filter of `query()` anyway.
|
|
464
|
+
*/
|
|
465
|
+
function idConditionFilter(condition: unknown, storedId: (id: string) => unknown): Document | undefined {
|
|
466
|
+
try {
|
|
467
|
+
if (typeof condition === "string") return { _id: storedId(condition) };
|
|
468
|
+
if (!isPlainObject(condition)) return undefined;
|
|
469
|
+
const operators: Document = {};
|
|
470
|
+
const eq = condition["$eq"];
|
|
471
|
+
if (typeof eq === "string") operators["$eq"] = storedId(eq);
|
|
472
|
+
const among = condition["$in"];
|
|
473
|
+
if (Array.isArray(among) && among.length > 0 && among.every((value) => typeof value === "string")) operators["$in"] = among.map((value: string) => storedId(value));
|
|
474
|
+
return Object.keys(operators).length > 0 ? { _id: operators } : undefined;
|
|
475
|
+
} catch {
|
|
476
|
+
// An id that the storage refuses: no condition is a correct filter (it can pass more)
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
232
481
|
/** The filter of one condition, or undefined when the filter cannot test it in the same way. */
|
|
233
|
-
function conditionFilter(path: string, condition: unknown): Document | undefined {
|
|
234
|
-
|
|
482
|
+
function conditionFilter(path: string, condition: unknown, options: SelectorPrefilterOptions, reserved: ReadonlySet<string>): Document | undefined {
|
|
483
|
+
if (path === "_id" && options.storedId) return idConditionFilter(condition, options.storedId);
|
|
484
|
+
const field = storedPath(path, reserved);
|
|
235
485
|
if (field === undefined) return undefined;
|
|
236
486
|
if (isScalar(condition)) return { [field]: condition };
|
|
237
487
|
if (!isPlainObject(condition)) return undefined;
|
|
@@ -247,21 +497,25 @@ function conditionFilter(path: string, condition: unknown): Document | undefined
|
|
|
247
497
|
* This function gives a filter that each item of the selector passes (a filter can pass more),
|
|
248
498
|
* or undefined for no condition. Only conditions that MongoDB tests in the same way go in it.
|
|
249
499
|
*/
|
|
250
|
-
export function selectorPrefilter(selector: StorageSelector): Document | undefined {
|
|
500
|
+
export function selectorPrefilter(selector: StorageSelector, options: SelectorPrefilterOptions = {}): Document | undefined {
|
|
501
|
+
return prefilter(selector, options, new Set(options.storageFields ?? []));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function prefilter(selector: StorageSelector, options: SelectorPrefilterOptions, reserved: ReadonlySet<string>): Document | undefined {
|
|
251
505
|
const parts: Document[] = [];
|
|
252
506
|
for (const [key, condition] of Object.entries(selector as Record<string, unknown>)) {
|
|
253
507
|
if (condition === undefined) continue;
|
|
254
508
|
if (key === "$and" && Array.isArray(condition)) {
|
|
255
509
|
for (const branch of condition) {
|
|
256
|
-
const filter = isPlainObject(branch) ?
|
|
510
|
+
const filter = isPlainObject(branch) ? prefilter(branch as StorageSelector, options, reserved) : undefined;
|
|
257
511
|
if (filter) parts.push(filter);
|
|
258
512
|
}
|
|
259
513
|
} else if (key === "$or" && Array.isArray(condition) && condition.length > 0) {
|
|
260
|
-
const branches = condition.map((branch) => (isPlainObject(branch) ?
|
|
514
|
+
const branches = condition.map((branch) => (isPlainObject(branch) ? prefilter(branch as StorageSelector, options, reserved) : undefined));
|
|
261
515
|
// A branch without a condition passes each item: then the $or tests nothing
|
|
262
516
|
if (branches.every((branch): branch is Document => branch !== undefined)) parts.push({ $or: branches });
|
|
263
517
|
} else if (!key.startsWith("$")) {
|
|
264
|
-
const filter = conditionFilter(key, condition);
|
|
518
|
+
const filter = conditionFilter(key, condition, options, reserved);
|
|
265
519
|
if (filter) parts.push(filter);
|
|
266
520
|
}
|
|
267
521
|
}
|
|
@@ -294,6 +548,14 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
294
548
|
private readonly writeLeaseMs: number;
|
|
295
549
|
/** The storages of the partitions, for `forPartition`: the same storage for the same partition. */
|
|
296
550
|
private readonly partitions: Map<string, MongoDocumentStorage<T>>;
|
|
551
|
+
/** The stored form of the documents (roadmap CLI-76). A `ViewMaterializer` of the collection takes the same form. */
|
|
552
|
+
readonly form: StoredForm;
|
|
553
|
+
/** The fields that name the partition, in their order. An `undefined` value is an absent field. */
|
|
554
|
+
private readonly partitionEntries: ReadonlyArray<readonly [string, unknown]>;
|
|
555
|
+
/** The values of the partition fields that are not fields of the storage, by name. */
|
|
556
|
+
private readonly partitionValues: ReadonlyMap<string, unknown>;
|
|
557
|
+
/** The fields that only the storage writes: its fields and the partition fields. */
|
|
558
|
+
private readonly reserved: ReadonlySet<string>;
|
|
297
559
|
private indexes: Promise<void> | undefined;
|
|
298
560
|
private closed = false;
|
|
299
561
|
|
|
@@ -301,6 +563,21 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
301
563
|
if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
|
|
302
564
|
throw new Error("A partition must be a text without the character U+001F");
|
|
303
565
|
}
|
|
566
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
567
|
+
for (const name of ["storedId", "idOf", "partitionFields", "partitionOf"] as const) {
|
|
568
|
+
if (typeof form[name] !== "function") throw new TypeError(`The stored form must have the function ${name}`);
|
|
569
|
+
}
|
|
570
|
+
this.form = form;
|
|
571
|
+
const fields = form.partitionFields(options.partition);
|
|
572
|
+
if (!isPlainObject(fields) || Object.keys(fields).length === 0) throw new TypeError("partitionFields must give an object with at least one field");
|
|
573
|
+
for (const name of Object.keys(fields)) {
|
|
574
|
+
if (name === "_id" || name.startsWith("$") || name.includes(".") || name === "__proto__" || (STORAGE_FIELDS.has(name) && name !== PART)) {
|
|
575
|
+
throw new TypeError(`partitionFields cannot have the field ${JSON.stringify(name)}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
this.partitionEntries = Object.entries(fields);
|
|
579
|
+
this.partitionValues = new Map(this.partitionEntries.filter(([name]) => !STORAGE_FIELDS.has(name)));
|
|
580
|
+
this.reserved = new Set([...STORAGE_FIELDS, ...Object.keys(fields)]);
|
|
304
581
|
this.options = options;
|
|
305
582
|
this.collectionName = options.collection;
|
|
306
583
|
this.partition = options.partition;
|
|
@@ -334,8 +611,9 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
334
611
|
private async collection(): Promise<{ db: Db; items: Collection<Stored> }> {
|
|
335
612
|
const db = await this.database();
|
|
336
613
|
const items = db.collection<Stored>(this.collectionName);
|
|
614
|
+
const prefix = Object.fromEntries(this.partitionEntries.map(([name]) => [name, 1]));
|
|
337
615
|
this.indexes ??= items
|
|
338
|
-
.createIndexes([{ key: {
|
|
616
|
+
.createIndexes([{ key: { ...prefix, [SEQ]: 1 } }, { key: { ...prefix, [ORD]: 1 } }, { key: { ...prefix, [TOMB]: 1 } }])
|
|
339
617
|
.then(() => undefined)
|
|
340
618
|
.catch((error: unknown) => {
|
|
341
619
|
this.indexes = undefined;
|
|
@@ -347,7 +625,14 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
347
625
|
|
|
348
626
|
/** The filter of the documents of the partition. */
|
|
349
627
|
private partitionFilter(): Document {
|
|
350
|
-
|
|
628
|
+
// An object value is an equality too, never a set of operators
|
|
629
|
+
const condition = (value: unknown): unknown => (value === undefined ? { $exists: false } : typeof value === "object" && value !== null ? { $eq: value } : value);
|
|
630
|
+
return Object.fromEntries(this.partitionEntries.map(([name, value]) => [name, condition(value)]));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** This method puts the partition fields on a stored document, in their order. An absent field stays absent. */
|
|
634
|
+
private putPartition(doc: Stored): void {
|
|
635
|
+
for (const [name, value] of this.partitionEntries) if (value !== undefined) put(doc, name, value);
|
|
351
636
|
}
|
|
352
637
|
|
|
353
638
|
/** The filter of the items (not the tombstones) of the partition. */
|
|
@@ -356,9 +641,25 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
356
641
|
}
|
|
357
642
|
|
|
358
643
|
/** The stored `_id` of an id. An id with the separator could name an item of a partition: it is refused. */
|
|
359
|
-
private storedId(id: string):
|
|
644
|
+
private storedId(id: string): unknown {
|
|
360
645
|
if (typeof id !== "string" || id.includes(SEPARATOR)) throw new Error("An id must be a text without the character U+001F");
|
|
361
|
-
|
|
646
|
+
const stored = this.form.storedId(this.partition, id);
|
|
647
|
+
if (stored === undefined || stored === null) throw new TypeError(`storedId gave ${String(stored)} for the id ${JSON.stringify(id)}`);
|
|
648
|
+
return stored;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The id of a stored document: the inverse of its stored `_id`, else `_key`, or undefined when neither tells it. */
|
|
652
|
+
private idOfDoc(doc: Document): string | undefined {
|
|
653
|
+
const id = this.form.idOf(doc["_id"], this.partition);
|
|
654
|
+
if (typeof id === "string") return id;
|
|
655
|
+
const key = doc[KEY];
|
|
656
|
+
return typeof key === "string" ? key : undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** The place of a stored document in the order of the entries. A document without `_ord` comes first. */
|
|
660
|
+
private placeOf(doc: Document): number {
|
|
661
|
+
const ord = doc[ORD];
|
|
662
|
+
return typeof ord === "number" ? ord : 0;
|
|
362
663
|
}
|
|
363
664
|
|
|
364
665
|
/** The filter of a write of one stored document: its `_id` and the partition. */
|
|
@@ -369,25 +670,61 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
369
670
|
/** The stored document of an item, with the number of this write and the number of its place. */
|
|
370
671
|
private encode(id: string, value: T, seq: number, ord: number): Stored {
|
|
371
672
|
const doc: Stored = { _id: this.storedId(id), [KEY]: id, [SEQ]: seq, [ORD]: ord };
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
if (keepsId) doc[KEEP_ID] = true;
|
|
376
|
-
|
|
673
|
+
this.putPartition(doc);
|
|
674
|
+
const flat = this.flatOf(id, value);
|
|
675
|
+
if (flat) {
|
|
676
|
+
if (flat.keepsId) doc[KEEP_ID] = true;
|
|
677
|
+
if (flat.kept.length > 0) doc[KEEP_PART] = flat.kept;
|
|
678
|
+
for (const [key, field] of flat.fields) put(doc, key, field);
|
|
377
679
|
} else {
|
|
378
680
|
doc[JSON_FIELD] = JSON.stringify(value);
|
|
379
681
|
}
|
|
380
682
|
return doc;
|
|
381
683
|
}
|
|
382
684
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
685
|
+
/**
|
|
686
|
+
* The fields of the flat document of an item, or undefined when only JSON text keeps it: an item
|
|
687
|
+
* that is not a plain object, an `_id` other than its id, a top-level key of the storage or with
|
|
688
|
+
* `$`, a key `__proto__`, a class instance other than `Date`. It throws a `TypeError` for a flat
|
|
689
|
+
* item that a document cannot keep: a partition field with another value than the partition, or
|
|
690
|
+
* an object that MongoDB gives back as a `DBRef`.
|
|
691
|
+
*/
|
|
692
|
+
private flatOf(id: string, value: T): FlatItem | undefined {
|
|
693
|
+
if (!isPlainObject(value)) return undefined;
|
|
694
|
+
if ("_id" in value && value["_id"] !== id) return undefined;
|
|
695
|
+
const flat: FlatItem = { keepsId: "_id" in value, kept: [], fields: [] };
|
|
696
|
+
const problems: string[] = [];
|
|
697
|
+
for (const [key, field] of Object.entries(value)) {
|
|
698
|
+
if (key === "_id") continue;
|
|
699
|
+
if (key.startsWith("$") || key === "__proto__" || STORAGE_FIELDS.has(key)) return undefined;
|
|
700
|
+
if (this.partitionValues.has(key)) {
|
|
701
|
+
// The partition field restores it on read; another value would put the document in another partition
|
|
702
|
+
if (isDeepStrictEqual(field, this.partitionValues.get(key))) flat.kept.push(key);
|
|
703
|
+
else problems.push(`the field ${key} names the partition and has another value than the partition`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (!isNativeValue(field, key, problems)) return undefined;
|
|
707
|
+
flat.fields.push([key, field]);
|
|
708
|
+
}
|
|
709
|
+
if (problems.length > 0) throw new TypeError(`The storage cannot keep the item ${JSON.stringify(id)}: ${problems.join("; ")}`);
|
|
710
|
+
return flat;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The id and the item of a stored document, or undefined when the document does not tell its id. */
|
|
714
|
+
private decode(doc: Document, known?: string): [string, T] | undefined {
|
|
715
|
+
const id = this.idOfDoc(doc) ?? known;
|
|
716
|
+
if (id === undefined) return undefined;
|
|
717
|
+
return [id, itemOf(doc, id, this.partitionEntries, false) as T];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The entries of stored documents, in their order. A document that does not tell its id is left out. */
|
|
721
|
+
private decodeAll(docs: readonly Document[]): Array<[string, T]> {
|
|
722
|
+
const entries: Array<[string, T]> = [];
|
|
723
|
+
for (const doc of docs) {
|
|
724
|
+
const entry = this.decode(doc);
|
|
725
|
+
if (entry) entries.push(entry);
|
|
726
|
+
}
|
|
727
|
+
return entries;
|
|
391
728
|
}
|
|
392
729
|
|
|
393
730
|
/**
|
|
@@ -559,10 +896,20 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
559
896
|
await db.collection<CounterDoc>(this.countersName).updateOne({ _id: this.floorId() }, { $max: { floor } }, { upsert: true });
|
|
560
897
|
}
|
|
561
898
|
|
|
899
|
+
/** The ids of stored documents, in their order. A document that does not tell its id is left out. */
|
|
900
|
+
private idsOf(docs: readonly Document[]): string[] {
|
|
901
|
+
const ids: string[] = [];
|
|
902
|
+
for (const doc of docs) {
|
|
903
|
+
const id = this.idOfDoc(doc);
|
|
904
|
+
if (id !== undefined) ids.push(id);
|
|
905
|
+
}
|
|
906
|
+
return ids;
|
|
907
|
+
}
|
|
908
|
+
|
|
562
909
|
/** The items of the partition, in the order of the entries. */
|
|
563
910
|
private async liveDocs(extra?: Document): Promise<Document[]> {
|
|
564
911
|
const { items } = await this.collection();
|
|
565
|
-
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1 }).toArray();
|
|
912
|
+
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
566
913
|
}
|
|
567
914
|
|
|
568
915
|
/** The places (`_ord`) of the items of some ids that the partition has. */
|
|
@@ -570,7 +917,12 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
570
917
|
const docs = await collection
|
|
571
918
|
.find(this.liveFilter({ _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } }), { projection: { [KEY]: 1, [ORD]: 1 } })
|
|
572
919
|
.toArray();
|
|
573
|
-
|
|
920
|
+
const places = new Map<string, number>();
|
|
921
|
+
for (const doc of docs) {
|
|
922
|
+
const id = this.idOfDoc(doc);
|
|
923
|
+
if (id !== undefined) places.set(id, this.placeOf(doc));
|
|
924
|
+
}
|
|
925
|
+
return places;
|
|
574
926
|
}
|
|
575
927
|
|
|
576
928
|
// ===========================================================================
|
|
@@ -580,21 +932,21 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
580
932
|
async get(id: string): Promise<T | undefined> {
|
|
581
933
|
const { items } = await this.collection();
|
|
582
934
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
583
|
-
return doc ? this.decode(doc)[1] : undefined;
|
|
935
|
+
return doc ? this.decode(doc, id)?.[1] : undefined;
|
|
584
936
|
}
|
|
585
937
|
|
|
586
938
|
async getAll(): Promise<T[]> {
|
|
587
|
-
return (await this.liveDocs()).map((
|
|
939
|
+
return this.decodeAll(await this.liveDocs()).map(([, value]) => value);
|
|
588
940
|
}
|
|
589
941
|
|
|
590
942
|
async getEntries(): Promise<Array<[string, T]>> {
|
|
591
|
-
return (await this.liveDocs())
|
|
943
|
+
return this.decodeAll(await this.liveDocs());
|
|
592
944
|
}
|
|
593
945
|
|
|
594
946
|
async getIds(): Promise<string[]> {
|
|
595
947
|
const { items } = await this.collection();
|
|
596
|
-
const docs = await items.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
|
|
597
|
-
return
|
|
948
|
+
const docs = await items.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
949
|
+
return this.idsOf(docs);
|
|
598
950
|
}
|
|
599
951
|
|
|
600
952
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
@@ -605,9 +957,9 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
605
957
|
const rule = policy ?? this.options.queryPolicy;
|
|
606
958
|
// The reference checks the selector and the options first, without a read of the database
|
|
607
959
|
queryEntries([], selector, options, rule);
|
|
608
|
-
const filter = selectorPrefilter(selector);
|
|
960
|
+
const filter = selectorPrefilter(selector, { storedId: (id) => this.storedId(id), storageFields: this.reserved });
|
|
609
961
|
const docs = await this.liveDocs(filter ? { $or: [{ [JSON_FIELD]: { $exists: true } }, filter] } : undefined);
|
|
610
|
-
return queryEntries(
|
|
962
|
+
return queryEntries(this.decodeAll(docs), selector, options, rule) as Array<[string, T]>;
|
|
611
963
|
}
|
|
612
964
|
|
|
613
965
|
async has(id: string): Promise<boolean> {
|
|
@@ -617,7 +969,12 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
617
969
|
|
|
618
970
|
async size(): Promise<number> {
|
|
619
971
|
const { items } = await this.collection();
|
|
620
|
-
|
|
972
|
+
// A document with `_key` tells its id. A document from outside the storage counts only when `idOf` names it, as in getAll
|
|
973
|
+
const [all, outside] = await Promise.all([
|
|
974
|
+
items.countDocuments(this.liveFilter()),
|
|
975
|
+
items.find(this.liveFilter({ [KEY]: { $exists: false } }), { projection: { _id: 1 } }).toArray(),
|
|
976
|
+
]);
|
|
977
|
+
return all - (outside.length - this.idsOf(outside).length);
|
|
621
978
|
}
|
|
622
979
|
|
|
623
980
|
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
@@ -628,7 +985,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
628
985
|
}
|
|
629
986
|
const { items } = await this.collection();
|
|
630
987
|
const docs = await items.find(this.liveFilter({ _id: { $in: ids.map((id) => this.storedId(id)) } })).toArray();
|
|
631
|
-
const byId = new Map(
|
|
988
|
+
const byId = new Map(this.decodeAll(docs));
|
|
632
989
|
// The order of the ids of the call
|
|
633
990
|
for (const id of ids) if (byId.has(id)) result.set(id, byId.get(id)!);
|
|
634
991
|
return result;
|
|
@@ -654,11 +1011,15 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
654
1011
|
return cutResults(results, maxResults, bounded.limited);
|
|
655
1012
|
}
|
|
656
1013
|
|
|
657
|
-
/**
|
|
1014
|
+
/**
|
|
1015
|
+
* The version of an item: the sequence number of its last write, or undefined for a missing id.
|
|
1016
|
+
* An item without a write through the storage (no `_seq`) has the version `0`.
|
|
1017
|
+
*/
|
|
658
1018
|
async versionOf(id: string): Promise<string | undefined> {
|
|
659
1019
|
const { items } = await this.collection();
|
|
660
1020
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { [SEQ]: 1 } });
|
|
661
|
-
|
|
1021
|
+
if (!doc) return undefined;
|
|
1022
|
+
return typeof doc[SEQ] === "number" ? String(doc[SEQ]) : "0";
|
|
662
1023
|
}
|
|
663
1024
|
|
|
664
1025
|
async getChangesSince(since?: string): Promise<StorageChanges<T>> {
|
|
@@ -669,7 +1030,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
669
1030
|
const full = async (): Promise<StorageChanges<T>> => ({
|
|
670
1031
|
version,
|
|
671
1032
|
full: true,
|
|
672
|
-
changed: (await items.find(this.liveFilter()).sort({ [ORD]: 1 }).toArray())
|
|
1033
|
+
changed: this.decodeAll(await items.find(this.liveFilter()).sort({ [ORD]: 1, _id: 1 }).toArray()),
|
|
673
1034
|
deleted: [],
|
|
674
1035
|
});
|
|
675
1036
|
const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
|
|
@@ -686,8 +1047,10 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
686
1047
|
const changed: Array<[string, T]> = [];
|
|
687
1048
|
const deleted: string[] = [];
|
|
688
1049
|
for (const doc of docs) {
|
|
689
|
-
|
|
690
|
-
|
|
1050
|
+
const id = this.idOfDoc(doc);
|
|
1051
|
+
if (id === undefined) continue;
|
|
1052
|
+
if (doc[TOMB] === true) deleted.push(id);
|
|
1053
|
+
else changed.push(this.decode(doc, id)!);
|
|
691
1054
|
}
|
|
692
1055
|
return { version, full: false, changed, deleted };
|
|
693
1056
|
});
|
|
@@ -719,6 +1082,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
719
1082
|
// The ids and the encoding are checked before a number of the sequence goes
|
|
720
1083
|
for (const [id, value] of items) {
|
|
721
1084
|
this.storedId(id);
|
|
1085
|
+
this.flatOf(id, value);
|
|
722
1086
|
JSON.stringify(value);
|
|
723
1087
|
}
|
|
724
1088
|
await this.write(async (db, collection) => {
|
|
@@ -749,9 +1113,11 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
749
1113
|
return this.write(async (db, collection) => {
|
|
750
1114
|
const doc = await collection.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
751
1115
|
if (!doc) return undefined;
|
|
752
|
-
const next = applyPatch(this.decode(doc)[1], fields) as T;
|
|
1116
|
+
const next = applyPatch(this.decode(doc, id)![1], fields) as T;
|
|
1117
|
+
// The encoding is checked before a number of the sequence goes
|
|
1118
|
+
this.flatOf(id, next);
|
|
753
1119
|
await this.withNumbers(db, collection, 1, (first, session) =>
|
|
754
|
-
collection.replaceOne(this.writeFilter(id), this.encode(id, next, first,
|
|
1120
|
+
collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) })
|
|
755
1121
|
);
|
|
756
1122
|
return next;
|
|
757
1123
|
});
|
|
@@ -787,7 +1153,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
787
1153
|
.find(this.liveFilter({ _id: { $in: unique.map((id) => this.storedId(id)) } }), { projection: { _id: 1, [KEY]: 1 } })
|
|
788
1154
|
.toArray();
|
|
789
1155
|
if (live.length === 0) return 0;
|
|
790
|
-
const liveIds = new Set(
|
|
1156
|
+
const liveIds = new Set(this.idsOf(live));
|
|
791
1157
|
const deleted = unique.filter((id) => liveIds.has(id));
|
|
792
1158
|
await this.tombstone(db, collection, deleted);
|
|
793
1159
|
return deleted.length;
|
|
@@ -796,13 +1162,10 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
796
1162
|
|
|
797
1163
|
async clear(_options?: StorageWriteOptions): Promise<void> {
|
|
798
1164
|
await this.write(async (db, collection) => {
|
|
799
|
-
const live = await collection.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
collection,
|
|
804
|
-
live.map((doc) => String(doc[KEY]))
|
|
805
|
-
);
|
|
1165
|
+
const live = await collection.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
1166
|
+
const ids = this.idsOf(live);
|
|
1167
|
+
if (ids.length === 0) return;
|
|
1168
|
+
await this.tombstone(db, collection, ids);
|
|
806
1169
|
});
|
|
807
1170
|
}
|
|
808
1171
|
|
|
@@ -815,7 +1178,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
815
1178
|
const guards = await this.checkVersions(collection, expected, session);
|
|
816
1179
|
const operations: AnyBulkWriteOperation<Stored>[] = ids.map((id, index) => {
|
|
817
1180
|
const tomb: Stored = { _id: this.storedId(id), [KEY]: id, [SEQ]: first + index, [TOMB]: true };
|
|
818
|
-
|
|
1181
|
+
this.putPartition(tomb);
|
|
819
1182
|
return { replaceOne: { filter: { ...this.writeFilter(id), ...guards.get(id) }, replacement: tomb } };
|
|
820
1183
|
});
|
|
821
1184
|
const result = await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
|
|
@@ -841,12 +1204,18 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
841
1204
|
const docs = await collection
|
|
842
1205
|
.find({ ...this.partitionFilter(), _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } } as Filter<Stored>, session ? { session } : {})
|
|
843
1206
|
.toArray();
|
|
844
|
-
|
|
1207
|
+
const byId = new Map<string, Document>();
|
|
1208
|
+
for (const doc of docs) {
|
|
1209
|
+
const id = this.idOfDoc(doc);
|
|
1210
|
+
if (id !== undefined) byId.set(id, doc);
|
|
1211
|
+
}
|
|
1212
|
+
return byId;
|
|
845
1213
|
}
|
|
846
1214
|
|
|
847
1215
|
/** The current item of a stored document: no item for a tombstone or a missing document. */
|
|
848
1216
|
private currentOf(doc: Document | undefined): CurrentItem<T> {
|
|
849
|
-
|
|
1217
|
+
const entry = doc && doc[TOMB] !== true ? this.decode(doc) : undefined;
|
|
1218
|
+
return entry ? { found: true, value: entry[1] } : { found: false };
|
|
850
1219
|
}
|
|
851
1220
|
|
|
852
1221
|
/**
|
|
@@ -868,7 +1237,8 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
868
1237
|
if (session) return guards;
|
|
869
1238
|
for (const [id] of expected) {
|
|
870
1239
|
const doc = docs.get(id);
|
|
871
|
-
|
|
1240
|
+
// A document without `_seq` (not written through the storage yet) keeps that state until the write
|
|
1241
|
+
guards.set(id, doc && typeof doc[SEQ] === "number" ? { [SEQ]: doc[SEQ] } : { [SEQ]: { $exists: false } });
|
|
872
1242
|
}
|
|
873
1243
|
return guards;
|
|
874
1244
|
}
|