@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,10 +85,15 @@
|
|
|
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";
|
|
51
|
-
import
|
|
95
|
+
import { isDeepStrictEqual } from "node:util";
|
|
96
|
+
import { BSON, type AnyBulkWriteOperation, type ClientSession, type Collection, type Db, type Document, type Filter, type MongoClient } from "mongodb";
|
|
52
97
|
import {
|
|
53
98
|
VersionConflictError,
|
|
54
99
|
applyPatch,
|
|
@@ -70,6 +115,7 @@ import {
|
|
|
70
115
|
} from "@procedures/collections";
|
|
71
116
|
import { optionalMs, toMs, type Duration } from "@procedures/client";
|
|
72
117
|
import { ensureConnection } from "../connection.js";
|
|
118
|
+
import { noteOwnNumbers, notePrunedIds } from "./own-writes.js";
|
|
73
119
|
import { boundPipeline, cutResults, DEFAULT_PIPELINE_LIMITS, scopePipeline, type PipelineLimits } from "./pipeline-scope.js";
|
|
74
120
|
|
|
75
121
|
/** The fields of the storage in a stored document. */
|
|
@@ -82,10 +128,81 @@ const TOMB = "_tomb";
|
|
|
82
128
|
const JSON_FIELD = "_json";
|
|
83
129
|
/** True in a stored document whose item had `_id` equal to its id. */
|
|
84
130
|
const KEEP_ID = "_kid";
|
|
85
|
-
|
|
131
|
+
/** The partition fields that the item had with the value of its partition (roadmap CLI-76). The write leaves them out of the fields. */
|
|
132
|
+
const KEEP_PART = "_kpf";
|
|
133
|
+
const STORAGE_FIELDS: ReadonlySet<string> = new Set(["_id", KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
86
134
|
/** The separator of the partition and the id in a stored `_id`. */
|
|
87
135
|
export const SEPARATOR = "\u001f";
|
|
88
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The fields that a `MongoDocumentStorage` can add to a stored document, in each stored form
|
|
139
|
+
* (roadmap CLI-76). A reader of the raw documents can leave them out. The partition fields are
|
|
140
|
+
* not in the list.
|
|
141
|
+
*/
|
|
142
|
+
export const DOCUMENT_STORAGE_FIELDS: readonly string[] = Object.freeze([KEY, PART, SEQ, ORD, TOMB, JSON_FIELD, KEEP_ID, KEEP_PART]);
|
|
143
|
+
|
|
144
|
+
/** The field that is `true` in a tombstone: the document of a deleted item. A reader of the raw documents leaves them out. */
|
|
145
|
+
export const TOMBSTONE_FIELD: "_tomb" = TOMB;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The stored form of a `MongoDocumentStorage` (roadmap CLI-76): how an id and a partition become a
|
|
149
|
+
* stored document, and back. Give the same object to the storage (`form`) and to a
|
|
150
|
+
* `ViewMaterializer` (`storageSources`), or read it from `storage.form`.
|
|
151
|
+
*/
|
|
152
|
+
export interface StoredForm {
|
|
153
|
+
/**
|
|
154
|
+
* The stored `_id` of an id in a partition. It must give a value other than `undefined` and
|
|
155
|
+
* `null`, and two ids of one partition must not give the same value.
|
|
156
|
+
*/
|
|
157
|
+
storedId(partition: string | undefined, id: string): unknown;
|
|
158
|
+
/** The inverse of `storedId`: the id of a stored `_id`, or `undefined` when the `_id` is not of the partition. */
|
|
159
|
+
idOf(storedId: unknown, partition: string | undefined): string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* The fields that name a partition. Each document of the partition has them, and they are the
|
|
162
|
+
* filter of the partition. A field with the value `undefined` is absent: the document does not
|
|
163
|
+
* have it, and the filter is `{ $exists: false }`. Give the same names for each partition. A
|
|
164
|
+
* top-level field of an item with one of these names and the value of the partition is left out
|
|
165
|
+
* of the stored fields and noted in `_kpf`, and the read gives it back. Another value is refused
|
|
166
|
+
* with a `TypeError`.
|
|
167
|
+
*/
|
|
168
|
+
partitionFields(partition: string | undefined): Readonly<Record<string, unknown>>;
|
|
169
|
+
/**
|
|
170
|
+
* The partition of a stored document, or `undefined` when the document does not tell it. A
|
|
171
|
+
* `ViewMaterializer` gives the document of a change event: `fullDocument`, else
|
|
172
|
+
* `fullDocumentBeforeChange`, else `documentKey`. A delete event has only `documentKey`, so with
|
|
173
|
+
* another `storedId`, the `_id` must tell the partition, or the collection needs
|
|
174
|
+
* `changeStreamPreAndPostImages`. The storage writes a delete as a tombstone (a replace event with
|
|
175
|
+
* the whole document), so the gap is only for a raw hard delete.
|
|
176
|
+
*/
|
|
177
|
+
partitionOf(doc: Document): string | undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The default stored form, the form of 0.34.0. The stored `_id` is the id, or `<partition>` +
|
|
182
|
+
* U+001F + `<id>` with a partition. The partition field is `_part` (absent without a partition).
|
|
183
|
+
* `partitionOf` reads `_part`, else the part of `_id` before U+001F.
|
|
184
|
+
*/
|
|
185
|
+
export const DEFAULT_STORED_FORM: StoredForm = Object.freeze({
|
|
186
|
+
storedId(partition: string | undefined, id: string): string {
|
|
187
|
+
return partition === undefined ? id : `${partition}${SEPARATOR}${id}`;
|
|
188
|
+
},
|
|
189
|
+
idOf(storedId: unknown, partition: string | undefined): string | undefined {
|
|
190
|
+
if (typeof storedId !== "string") return undefined;
|
|
191
|
+
if (partition === undefined) return storedId.includes(SEPARATOR) ? undefined : storedId;
|
|
192
|
+
const prefix = `${partition}${SEPARATOR}`;
|
|
193
|
+
return storedId.startsWith(prefix) ? storedId.slice(prefix.length) : undefined;
|
|
194
|
+
},
|
|
195
|
+
partitionFields(partition: string | undefined): Record<string, unknown> {
|
|
196
|
+
return { [PART]: partition };
|
|
197
|
+
},
|
|
198
|
+
partitionOf(doc: Document): string | undefined {
|
|
199
|
+
if (typeof doc[PART] === "string") return doc[PART];
|
|
200
|
+
const id: unknown = doc["_id"];
|
|
201
|
+
const at = typeof id === "string" ? id.indexOf(SEPARATOR) : -1;
|
|
202
|
+
return at > 0 ? (id as string).slice(0, at) : undefined;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
89
206
|
/** The options of a `MongoDocumentStorage`. */
|
|
90
207
|
export interface MongoDocumentStorageOptions<T = unknown> {
|
|
91
208
|
/** The MongoDB collection. */
|
|
@@ -107,7 +224,8 @@ export interface MongoDocumentStorageOptions<T = unknown> {
|
|
|
107
224
|
writeLease?: Duration | undefined;
|
|
108
225
|
/**
|
|
109
226
|
* 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
|
|
227
|
+
* connection when `db` is absent. Without a client, the writes use the watermark: with `db` and
|
|
228
|
+
* no `client`, a write is not a transaction, also on a replica set.
|
|
111
229
|
*/
|
|
112
230
|
client?: (() => MongoClient | Promise<MongoClient>) | undefined;
|
|
113
231
|
/**
|
|
@@ -123,6 +241,11 @@ export interface MongoDocumentStorageOptions<T = unknown> {
|
|
|
123
241
|
* the sequence number of the last write.
|
|
124
242
|
*/
|
|
125
243
|
versionOf?: ((item: T) => string | undefined) | undefined;
|
|
244
|
+
/**
|
|
245
|
+
* The stored form (roadmap CLI-76): the stored `_id`, its inverse, the partition fields and the
|
|
246
|
+
* partition of a document. The default `DEFAULT_STORED_FORM` writes the documents of 0.34.0.
|
|
247
|
+
*/
|
|
248
|
+
form?: StoredForm | undefined;
|
|
126
249
|
}
|
|
127
250
|
|
|
128
251
|
/** True for the error of a duplicate key: an insert of an upsert whose filter found no document. */
|
|
@@ -132,8 +255,8 @@ function isDuplicateKey(error: unknown): boolean {
|
|
|
132
255
|
return code === 11000 || [writeErrors].flat().some((entry) => (entry as { code?: unknown } | null)?.code === 11000);
|
|
133
256
|
}
|
|
134
257
|
|
|
135
|
-
/** A stored document. */
|
|
136
|
-
type Stored = Document
|
|
258
|
+
/** A stored document. Its `_id` comes from `storedId`: a text by default, any value but `undefined` and `null` in another form. */
|
|
259
|
+
type Stored = Document;
|
|
137
260
|
|
|
138
261
|
/** A range of numbers that a write took and has not written yet. */
|
|
139
262
|
interface PendingRange {
|
|
@@ -190,20 +313,239 @@ function enqueue<R>(db: Db, collection: string, task: () => Promise<R>): Promise
|
|
|
190
313
|
return run;
|
|
191
314
|
}
|
|
192
315
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
316
|
+
/**
|
|
317
|
+
* True for a value of a field that BSON gives back as it is: a key with `$` or a dot stays as it is
|
|
318
|
+
* (MongoDB 5.0 or later). False when only JSON text keeps it: a class instance other than `Date`,
|
|
319
|
+
* or a key `__proto__`. An object that BSON gives back as a `DBRef` adds a problem.
|
|
320
|
+
*/
|
|
321
|
+
function isNativeValue(value: unknown, path: string, problems: string[]): boolean {
|
|
322
|
+
if (Array.isArray(value)) return value.every((element, index) => isNativeValue(element, `${path}.${index}`, problems));
|
|
196
323
|
if (typeof value !== "object" || value === null) return true;
|
|
197
324
|
const proto = Object.getPrototypeOf(value) as unknown;
|
|
198
325
|
if (proto !== Object.prototype && proto !== null) return value instanceof Date;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
326
|
+
const object = value as Record<string, unknown>;
|
|
327
|
+
// The test of the BSON reader: a text `$ref`, an `$id`, and no `$db` or a text `$db`
|
|
328
|
+
if (typeof object["$ref"] === "string" && object["$id"] !== undefined && object["$id"] !== null && (object["$db"] === undefined || typeof object["$db"] === "string")) {
|
|
329
|
+
problems.push(`the field ${path} has $ref and $id, and MongoDB gives it back as a DBRef`);
|
|
330
|
+
}
|
|
331
|
+
for (const [key, field] of Object.entries(object)) {
|
|
332
|
+
if (key === "__proto__") return false;
|
|
333
|
+
if (!isNativeValue(field, `${path}.${key}`, problems)) return false;
|
|
203
334
|
}
|
|
204
335
|
return true;
|
|
205
336
|
}
|
|
206
337
|
|
|
338
|
+
/** The fields of the flat document of an item. */
|
|
339
|
+
interface FlatItem {
|
|
340
|
+
/** True when the item has `_id`, equal to its id. */
|
|
341
|
+
keepsId: boolean;
|
|
342
|
+
/** The partition fields that the item has with the value of its partition: the write leaves them out. */
|
|
343
|
+
kept: string[];
|
|
344
|
+
fields: Array<[string, unknown]>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The item of a stored document (not a tombstone) whose id is known. `_json` gives the item as it
|
|
349
|
+
* was. Else the fields of the storage stay out, the item gets `_id` when it had it (`_kid`), when
|
|
350
|
+
* the document is from outside the storage (no `_key`) or with `addId`, and it gets back the
|
|
351
|
+
* partition fields of `_kpf`. A partition field with the value of the partition stays out (for a
|
|
352
|
+
* document from outside the storage too); a field with another value stays in.
|
|
353
|
+
*/
|
|
354
|
+
function itemOf(doc: Document, id: string, partitionEntries: ReadonlyArray<readonly [string, unknown]>, addId: boolean): unknown {
|
|
355
|
+
if (typeof doc[JSON_FIELD] === "string") {
|
|
356
|
+
const item = JSON.parse(doc[JSON_FIELD]) as unknown;
|
|
357
|
+
if (addId && isPlainObject(item) && !("_id" in item)) return { _id: id, ...item };
|
|
358
|
+
return item;
|
|
359
|
+
}
|
|
360
|
+
const outside = doc[KEY] === undefined;
|
|
361
|
+
const value: Record<string, unknown> = {};
|
|
362
|
+
if (addId || outside || doc[KEEP_ID] === true) value["_id"] = id;
|
|
363
|
+
const partitionValues = new Map(partitionEntries);
|
|
364
|
+
for (const [key, field] of Object.entries(doc)) {
|
|
365
|
+
if (STORAGE_FIELDS.has(key)) continue;
|
|
366
|
+
// The value of the partition comes back only through `_kpf`, so the item has one form at each age of its document
|
|
367
|
+
if (partitionValues.has(key) && isDeepStrictEqual(field, partitionValues.get(key))) continue;
|
|
368
|
+
put(value, key, field);
|
|
369
|
+
}
|
|
370
|
+
const kept = doc[KEEP_PART];
|
|
371
|
+
if (Array.isArray(kept)) {
|
|
372
|
+
for (const [name, field] of partitionEntries) if (kept.includes(name) && !STORAGE_FIELDS.has(name)) put(value, name, field);
|
|
373
|
+
}
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The options of `decodeStoredDocument`. */
|
|
378
|
+
export interface DecodeStoredDocumentOptions {
|
|
379
|
+
/** The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`). */
|
|
380
|
+
form?: StoredForm | undefined;
|
|
381
|
+
/** The partition of the document. The default is `form.partitionOf(doc)`. */
|
|
382
|
+
partition?: string | undefined;
|
|
383
|
+
/**
|
|
384
|
+
* True (the default): an item that is a plain object without `_id` gets its id as `_id`. False:
|
|
385
|
+
* the item is as the storage gives it, with `_id` only when it had it.
|
|
386
|
+
*/
|
|
387
|
+
addId?: boolean | undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** An item of a stored document, from `decodeStoredDocument`. */
|
|
391
|
+
export interface StoredDocumentEntry<T> {
|
|
392
|
+
/** The id of the item. */
|
|
393
|
+
id: string;
|
|
394
|
+
/** The partition of the document. */
|
|
395
|
+
partition: string | undefined;
|
|
396
|
+
/** The item. */
|
|
397
|
+
item: T;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* This function reads a raw document of a `MongoDocumentStorage` collection as the storage reads
|
|
402
|
+
* it (roadmap CLI-76), for an app that reads the collection directly (a query, a view, an
|
|
403
|
+
* aggregation). It gives `undefined` for a tombstone and for a document whose id neither `idOf`
|
|
404
|
+
* nor `_key` tells. Else it parses `_json`, leaves out the fields of the storage
|
|
405
|
+
* (`DOCUMENT_STORAGE_FIELDS`) and gives the item its id as `_id` (see `addId`).
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* ```typescript
|
|
409
|
+
* const items = docs.flatMap((doc) => decodeStoredDocument<Note>(doc, { form: notes.form })?.item ?? []);
|
|
410
|
+
* ```
|
|
411
|
+
*/
|
|
412
|
+
export function decodeStoredDocument<T = Document>(doc: Document, options: DecodeStoredDocumentOptions = {}): StoredDocumentEntry<T> | undefined {
|
|
413
|
+
if (!isPlainObject(doc) || doc[TOMB] === true) return undefined;
|
|
414
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
415
|
+
const partition = options.partition ?? form.partitionOf(doc);
|
|
416
|
+
const fromId = form.idOf(doc["_id"], partition);
|
|
417
|
+
const id = typeof fromId === "string" ? fromId : typeof doc[KEY] === "string" ? doc[KEY] : undefined;
|
|
418
|
+
if (id === undefined) return undefined;
|
|
419
|
+
const entries = Object.entries(form.partitionFields(partition));
|
|
420
|
+
return { id, partition, item: itemOf(doc, id, entries, options.addId ?? true) as T };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* This function gives the version of a raw stored document of a `MongoDocumentStorage`: the
|
|
425
|
+
* version that `storage.versionOf(id)` gives for the same document (roadmap CLI-77). The storage
|
|
426
|
+
* uses it too, so the two never differ.
|
|
427
|
+
*
|
|
428
|
+
* - The version is the sequence number of the last write through the storage (`_seq`), as text.
|
|
429
|
+
* - A document without `_seq` (an old document, or one that a raw replace wrote) has the version `0`.
|
|
430
|
+
* - A tombstone, a value that is not a plain object, and a document whose id neither `idOf` nor
|
|
431
|
+
* `_key` tells give `undefined`: they are no items, as for `decodeStoredDocument`.
|
|
432
|
+
*
|
|
433
|
+
* @param doc - The stored document, for example the `fullDocument` of a change event
|
|
434
|
+
* @param form - The stored form of the storage, for example `storage.form` (default `DEFAULT_STORED_FORM`)
|
|
435
|
+
* @param partition - The partition of the document. The default is `form.partitionOf(doc)`.
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* ```typescript
|
|
439
|
+
* const version = versionOfStored(change.fullDocument, notes.form); // the same as await notes.versionOf(id)
|
|
440
|
+
* ```
|
|
441
|
+
*/
|
|
442
|
+
export function versionOfStored(doc: Document, form: StoredForm = DEFAULT_STORED_FORM, partition?: string): string | undefined {
|
|
443
|
+
if (!isPlainObject(doc) || doc[TOMB] === true) return undefined;
|
|
444
|
+
const place = partition ?? form.partitionOf(doc);
|
|
445
|
+
if (typeof form.idOf(doc["_id"], place) !== "string" && typeof doc[KEY] !== "string") return undefined;
|
|
446
|
+
return seqVersion(doc);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The version of the `_seq` of a stored document: the number as text, or `0` without it. */
|
|
450
|
+
function seqVersion(doc: Document): string {
|
|
451
|
+
const seq = doc[SEQ];
|
|
452
|
+
return typeof seq === "number" ? String(seq) : "0";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The parts of a storage that `watchStorageChanges` reads (roadmap CLI-77). The package does not
|
|
457
|
+
* export this type: it is for the modules of the storage.
|
|
458
|
+
*/
|
|
459
|
+
export interface StorageChangeSource {
|
|
460
|
+
/** The database of the storage. */
|
|
461
|
+
db(): Promise<Db>;
|
|
462
|
+
/** The MongoDB collection of the items. */
|
|
463
|
+
collection: string;
|
|
464
|
+
/** The collection of the sequence counters. */
|
|
465
|
+
counters: string;
|
|
466
|
+
/** The stored form. */
|
|
467
|
+
form: StoredForm;
|
|
468
|
+
/** The partition of the storage, or undefined for the storage of the whole collection. */
|
|
469
|
+
partition: string | undefined;
|
|
470
|
+
/** This method adopts a raw write (D39): see `RawStorageWrite`. */
|
|
471
|
+
adopt(raw: RawStorageWrite): Promise<RawAdoption>;
|
|
472
|
+
/**
|
|
473
|
+
* This method adopts several raw writes: the writes of each partition take one block of numbers.
|
|
474
|
+
* A partition whose writes fail gives `{ error }` for each of its writes.
|
|
475
|
+
*/
|
|
476
|
+
adoptAll(raws: readonly RawStorageWrite[]): Promise<RawAdoptionResult[]>;
|
|
477
|
+
/**
|
|
478
|
+
* One page of the documents without `_seq` (the sweep of the raw writes), in the order of `_id`,
|
|
479
|
+
* after the `_id` of `after` (from the start without it).
|
|
480
|
+
*/
|
|
481
|
+
rawDocuments(after: { readonly id: unknown } | undefined, limit: number): Promise<Document[]>;
|
|
482
|
+
/** The stored documents of these stored `_id`s: the watcher adopts a raw write again after a full queue or a failure. */
|
|
483
|
+
storedDocuments(ids: readonly unknown[]): Promise<Document[]>;
|
|
484
|
+
/**
|
|
485
|
+
* The partitions that the storage knows: each partition with a floor document in the counters
|
|
486
|
+
* (the first write of a storage and each prune write one), and each partition of
|
|
487
|
+
* `forPartition()` in this process. It rejects when the counters cannot be read.
|
|
488
|
+
*/
|
|
489
|
+
partitions(): Promise<Array<string | undefined>>;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* A raw write of the collection (D39): a change event of a document that the storage did not write.
|
|
494
|
+
* The storage adopts it: one conditional write gives the document a new `_seq` through the path of
|
|
495
|
+
* each write (the transaction, or the watermark and the late move), so `changes(since)` has it.
|
|
496
|
+
*
|
|
497
|
+
* - The filter is the stored `_id` and the `_seq` that the event saw (or no `_seq`): when the
|
|
498
|
+
* document changed since (a later adoption, or a write of the storage), the adoption does
|
|
499
|
+
* nothing. Thus one adoption wins.
|
|
500
|
+
* - The adoption sets `_seq`, and the storage fields and partition fields that the document does
|
|
501
|
+
* not have: from the image before the change, else from the stored `_id` (`form.idOf()`,
|
|
502
|
+
* `form.partitionOf()`). It never changes a field of the item.
|
|
503
|
+
* - A hard delete (no `after`) needs the image before the change: the adoption inserts a tombstone
|
|
504
|
+
* with a new `_seq`, and the insert fails when the `_id` exists again.
|
|
505
|
+
* - The numbers of an adoption are never noted as numbers of this process: its change event is
|
|
506
|
+
* the hint of the raw write.
|
|
507
|
+
*/
|
|
508
|
+
export interface RawStorageWrite {
|
|
509
|
+
/** The stored `_id` of the document (`documentKey._id`). */
|
|
510
|
+
readonly storedId: unknown;
|
|
511
|
+
/** The `_seq` of the state that the event saw, or undefined for a document without `_seq`. */
|
|
512
|
+
readonly seq: number | undefined;
|
|
513
|
+
/** The document after the write, or undefined for a hard delete. */
|
|
514
|
+
readonly after?: Document | undefined;
|
|
515
|
+
/** The image of the document before the change, when the collection keeps it (`changeStreamPreAndPostImages`). */
|
|
516
|
+
readonly before?: Document | undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The result of an adoption: `adopted`, `lost` (the document changed since: another adoption or write won), or `unknown` (no image and no stored `_id` tells the id and the partition). */
|
|
520
|
+
export type RawAdoption = "adopted" | "lost" | "unknown";
|
|
521
|
+
|
|
522
|
+
/** True when two values have the same BSON. */
|
|
523
|
+
function sameBson(a: unknown, b: unknown): boolean {
|
|
524
|
+
try {
|
|
525
|
+
return Buffer.from(BSON.serialize({ v: a })).equals(Buffer.from(BSON.serialize({ v: b })));
|
|
526
|
+
} catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The result of one raw write of `adoptAll()`: a `RawAdoption`, or the error of the write of its partition. */
|
|
532
|
+
export type RawAdoptionResult = RawAdoption | { readonly error: unknown };
|
|
533
|
+
|
|
534
|
+
/** The most floor documents that `partitions()` reads. */
|
|
535
|
+
const MAX_KNOWN_PARTITIONS = 10_000;
|
|
536
|
+
|
|
537
|
+
function escapeRegex(text: string): string {
|
|
538
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The reader of the parts of a storage. The class sets it, so it can read its private fields. */
|
|
542
|
+
let sourceOfStorage: <T>(storage: MongoDocumentStorage<T>) => StorageChangeSource;
|
|
543
|
+
|
|
544
|
+
/** This function gives the parts of a storage that `watchStorageChanges` reads. The package does not export it. */
|
|
545
|
+
export function storageChangeSource<T>(storage: MongoDocumentStorage<T>): StorageChangeSource {
|
|
546
|
+
return sourceOfStorage(storage);
|
|
547
|
+
}
|
|
548
|
+
|
|
207
549
|
/** True for a plain object. */
|
|
208
550
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
209
551
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
@@ -221,17 +563,52 @@ function isScalar(value: unknown): value is string | number | boolean {
|
|
|
221
563
|
return typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
222
564
|
}
|
|
223
565
|
|
|
566
|
+
/** The options of `selectorPrefilter`. */
|
|
567
|
+
export interface SelectorPrefilterOptions {
|
|
568
|
+
/**
|
|
569
|
+
* The stored `_id` of an id. With it, a condition on `_id` with text values tests the stored
|
|
570
|
+
* `_id` (and uses its index). Without it, the condition tests `_key`.
|
|
571
|
+
*/
|
|
572
|
+
storedId?: ((id: string) => unknown) | undefined;
|
|
573
|
+
/** More fields that only the storage writes, for example the partition fields: a condition on one of them does not go in the filter. */
|
|
574
|
+
storageFields?: Iterable<string> | undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
224
577
|
/** 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 {
|
|
578
|
+
function storedPath(path: string, reserved: ReadonlySet<string>): string | undefined {
|
|
226
579
|
if (path === "_id") return KEY;
|
|
227
|
-
const
|
|
228
|
-
|
|
580
|
+
const parts = path.split(".");
|
|
581
|
+
const first = parts[0]!;
|
|
582
|
+
if (first === "_id" || STORAGE_FIELDS.has(first) || reserved.has(first)) return undefined;
|
|
583
|
+
// A part with `$` is a field name of an item, but an operator for MongoDB
|
|
584
|
+
if (parts.some((part) => part.startsWith("$"))) return undefined;
|
|
229
585
|
return path;
|
|
230
586
|
}
|
|
231
587
|
|
|
588
|
+
/**
|
|
589
|
+
* The filter of a condition on `_id` against the stored `_id`, or undefined. A flat item with an
|
|
590
|
+
* `_id` has its id as `_id`, and an item stored as JSON passes the filter of `query()` anyway.
|
|
591
|
+
*/
|
|
592
|
+
function idConditionFilter(condition: unknown, storedId: (id: string) => unknown): Document | undefined {
|
|
593
|
+
try {
|
|
594
|
+
if (typeof condition === "string") return { _id: storedId(condition) };
|
|
595
|
+
if (!isPlainObject(condition)) return undefined;
|
|
596
|
+
const operators: Document = {};
|
|
597
|
+
const eq = condition["$eq"];
|
|
598
|
+
if (typeof eq === "string") operators["$eq"] = storedId(eq);
|
|
599
|
+
const among = condition["$in"];
|
|
600
|
+
if (Array.isArray(among) && among.length > 0 && among.every((value) => typeof value === "string")) operators["$in"] = among.map((value: string) => storedId(value));
|
|
601
|
+
return Object.keys(operators).length > 0 ? { _id: operators } : undefined;
|
|
602
|
+
} catch {
|
|
603
|
+
// An id that the storage refuses: no condition is a correct filter (it can pass more)
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
232
608
|
/** 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
|
-
|
|
609
|
+
function conditionFilter(path: string, condition: unknown, options: SelectorPrefilterOptions, reserved: ReadonlySet<string>): Document | undefined {
|
|
610
|
+
if (path === "_id" && options.storedId) return idConditionFilter(condition, options.storedId);
|
|
611
|
+
const field = storedPath(path, reserved);
|
|
235
612
|
if (field === undefined) return undefined;
|
|
236
613
|
if (isScalar(condition)) return { [field]: condition };
|
|
237
614
|
if (!isPlainObject(condition)) return undefined;
|
|
@@ -247,21 +624,25 @@ function conditionFilter(path: string, condition: unknown): Document | undefined
|
|
|
247
624
|
* This function gives a filter that each item of the selector passes (a filter can pass more),
|
|
248
625
|
* or undefined for no condition. Only conditions that MongoDB tests in the same way go in it.
|
|
249
626
|
*/
|
|
250
|
-
export function selectorPrefilter(selector: StorageSelector): Document | undefined {
|
|
627
|
+
export function selectorPrefilter(selector: StorageSelector, options: SelectorPrefilterOptions = {}): Document | undefined {
|
|
628
|
+
return prefilter(selector, options, new Set(options.storageFields ?? []));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function prefilter(selector: StorageSelector, options: SelectorPrefilterOptions, reserved: ReadonlySet<string>): Document | undefined {
|
|
251
632
|
const parts: Document[] = [];
|
|
252
633
|
for (const [key, condition] of Object.entries(selector as Record<string, unknown>)) {
|
|
253
634
|
if (condition === undefined) continue;
|
|
254
635
|
if (key === "$and" && Array.isArray(condition)) {
|
|
255
636
|
for (const branch of condition) {
|
|
256
|
-
const filter = isPlainObject(branch) ?
|
|
637
|
+
const filter = isPlainObject(branch) ? prefilter(branch as StorageSelector, options, reserved) : undefined;
|
|
257
638
|
if (filter) parts.push(filter);
|
|
258
639
|
}
|
|
259
640
|
} else if (key === "$or" && Array.isArray(condition) && condition.length > 0) {
|
|
260
|
-
const branches = condition.map((branch) => (isPlainObject(branch) ?
|
|
641
|
+
const branches = condition.map((branch) => (isPlainObject(branch) ? prefilter(branch as StorageSelector, options, reserved) : undefined));
|
|
261
642
|
// A branch without a condition passes each item: then the $or tests nothing
|
|
262
643
|
if (branches.every((branch): branch is Document => branch !== undefined)) parts.push({ $or: branches });
|
|
263
644
|
} else if (!key.startsWith("$")) {
|
|
264
|
-
const filter = conditionFilter(key, condition);
|
|
645
|
+
const filter = conditionFilter(key, condition, options, reserved);
|
|
265
646
|
if (filter) parts.push(filter);
|
|
266
647
|
}
|
|
267
648
|
}
|
|
@@ -294,13 +675,53 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
294
675
|
private readonly writeLeaseMs: number;
|
|
295
676
|
/** The storages of the partitions, for `forPartition`: the same storage for the same partition. */
|
|
296
677
|
private readonly partitions: Map<string, MongoDocumentStorage<T>>;
|
|
678
|
+
/** The stored form of the documents (roadmap CLI-76). A `ViewMaterializer` of the collection takes the same form. */
|
|
679
|
+
readonly form: StoredForm;
|
|
680
|
+
/** The fields that name the partition, in their order. An `undefined` value is an absent field. */
|
|
681
|
+
private readonly partitionEntries: ReadonlyArray<readonly [string, unknown]>;
|
|
682
|
+
/** The values of the partition fields that are not fields of the storage, by name. */
|
|
683
|
+
private readonly partitionValues: ReadonlyMap<string, unknown>;
|
|
684
|
+
/** The fields that only the storage writes: its fields and the partition fields. */
|
|
685
|
+
private readonly reserved: ReadonlySet<string>;
|
|
297
686
|
private indexes: Promise<void> | undefined;
|
|
687
|
+
/** True after this storage wrote (or found) the floor document of its partition (roadmap O66). A failed write leaves it false. */
|
|
688
|
+
private floorNoted = false;
|
|
298
689
|
private closed = false;
|
|
299
690
|
|
|
691
|
+
static {
|
|
692
|
+
sourceOfStorage = <S>(storage: MongoDocumentStorage<S>): StorageChangeSource => ({
|
|
693
|
+
db: () => storage.database(),
|
|
694
|
+
collection: storage.collectionName,
|
|
695
|
+
counters: storage.countersName,
|
|
696
|
+
form: storage.form,
|
|
697
|
+
partition: storage.partition,
|
|
698
|
+
adopt: (raw) => storage.adoptRaw(raw),
|
|
699
|
+
adoptAll: (raws) => storage.adoptRawAll(raws),
|
|
700
|
+
rawDocuments: (after, limit) => storage.rawDocuments(after, limit),
|
|
701
|
+
storedDocuments: (ids) => storage.storedDocuments(ids),
|
|
702
|
+
partitions: () => storage.knownPartitions(),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
300
706
|
constructor(options: MongoDocumentStorageOptions<T>, partitions?: Map<string, MongoDocumentStorage<T>>) {
|
|
301
707
|
if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
|
|
302
708
|
throw new Error("A partition must be a text without the character U+001F");
|
|
303
709
|
}
|
|
710
|
+
const form = options.form ?? DEFAULT_STORED_FORM;
|
|
711
|
+
for (const name of ["storedId", "idOf", "partitionFields", "partitionOf"] as const) {
|
|
712
|
+
if (typeof form[name] !== "function") throw new TypeError(`The stored form must have the function ${name}`);
|
|
713
|
+
}
|
|
714
|
+
this.form = form;
|
|
715
|
+
const fields = form.partitionFields(options.partition);
|
|
716
|
+
if (!isPlainObject(fields) || Object.keys(fields).length === 0) throw new TypeError("partitionFields must give an object with at least one field");
|
|
717
|
+
for (const name of Object.keys(fields)) {
|
|
718
|
+
if (name === "_id" || name.startsWith("$") || name.includes(".") || name === "__proto__" || (STORAGE_FIELDS.has(name) && name !== PART)) {
|
|
719
|
+
throw new TypeError(`partitionFields cannot have the field ${JSON.stringify(name)}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
this.partitionEntries = Object.entries(fields);
|
|
723
|
+
this.partitionValues = new Map(this.partitionEntries.filter(([name]) => !STORAGE_FIELDS.has(name)));
|
|
724
|
+
this.reserved = new Set([...STORAGE_FIELDS, ...Object.keys(fields)]);
|
|
304
725
|
this.options = options;
|
|
305
726
|
this.collectionName = options.collection;
|
|
306
727
|
this.partition = options.partition;
|
|
@@ -334,8 +755,9 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
334
755
|
private async collection(): Promise<{ db: Db; items: Collection<Stored> }> {
|
|
335
756
|
const db = await this.database();
|
|
336
757
|
const items = db.collection<Stored>(this.collectionName);
|
|
758
|
+
const prefix = Object.fromEntries(this.partitionEntries.map(([name]) => [name, 1]));
|
|
337
759
|
this.indexes ??= items
|
|
338
|
-
.createIndexes([{ key: {
|
|
760
|
+
.createIndexes([{ key: { ...prefix, [SEQ]: 1 } }, { key: { ...prefix, [ORD]: 1 } }, { key: { ...prefix, [TOMB]: 1 } }])
|
|
339
761
|
.then(() => undefined)
|
|
340
762
|
.catch((error: unknown) => {
|
|
341
763
|
this.indexes = undefined;
|
|
@@ -347,7 +769,14 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
347
769
|
|
|
348
770
|
/** The filter of the documents of the partition. */
|
|
349
771
|
private partitionFilter(): Document {
|
|
350
|
-
|
|
772
|
+
// An object value is an equality too, never a set of operators
|
|
773
|
+
const condition = (value: unknown): unknown => (value === undefined ? { $exists: false } : typeof value === "object" && value !== null ? { $eq: value } : value);
|
|
774
|
+
return Object.fromEntries(this.partitionEntries.map(([name, value]) => [name, condition(value)]));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** This method puts the partition fields on a stored document, in their order. An absent field stays absent. */
|
|
778
|
+
private putPartition(doc: Stored): void {
|
|
779
|
+
for (const [name, value] of this.partitionEntries) if (value !== undefined) put(doc, name, value);
|
|
351
780
|
}
|
|
352
781
|
|
|
353
782
|
/** The filter of the items (not the tombstones) of the partition. */
|
|
@@ -356,9 +785,25 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
356
785
|
}
|
|
357
786
|
|
|
358
787
|
/** 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):
|
|
788
|
+
private storedId(id: string): unknown {
|
|
360
789
|
if (typeof id !== "string" || id.includes(SEPARATOR)) throw new Error("An id must be a text without the character U+001F");
|
|
361
|
-
|
|
790
|
+
const stored = this.form.storedId(this.partition, id);
|
|
791
|
+
if (stored === undefined || stored === null) throw new TypeError(`storedId gave ${String(stored)} for the id ${JSON.stringify(id)}`);
|
|
792
|
+
return stored;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The id of a stored document: the inverse of its stored `_id`, else `_key`, or undefined when neither tells it. */
|
|
796
|
+
private idOfDoc(doc: Document): string | undefined {
|
|
797
|
+
const id = this.form.idOf(doc["_id"], this.partition);
|
|
798
|
+
if (typeof id === "string") return id;
|
|
799
|
+
const key = doc[KEY];
|
|
800
|
+
return typeof key === "string" ? key : undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The place of a stored document in the order of the entries. A document without `_ord` comes first. */
|
|
804
|
+
private placeOf(doc: Document): number {
|
|
805
|
+
const ord = doc[ORD];
|
|
806
|
+
return typeof ord === "number" ? ord : 0;
|
|
362
807
|
}
|
|
363
808
|
|
|
364
809
|
/** The filter of a write of one stored document: its `_id` and the partition. */
|
|
@@ -369,25 +814,61 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
369
814
|
/** The stored document of an item, with the number of this write and the number of its place. */
|
|
370
815
|
private encode(id: string, value: T, seq: number, ord: number): Stored {
|
|
371
816
|
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
|
-
|
|
817
|
+
this.putPartition(doc);
|
|
818
|
+
const flat = this.flatOf(id, value);
|
|
819
|
+
if (flat) {
|
|
820
|
+
if (flat.keepsId) doc[KEEP_ID] = true;
|
|
821
|
+
if (flat.kept.length > 0) doc[KEEP_PART] = flat.kept;
|
|
822
|
+
for (const [key, field] of flat.fields) put(doc, key, field);
|
|
377
823
|
} else {
|
|
378
824
|
doc[JSON_FIELD] = JSON.stringify(value);
|
|
379
825
|
}
|
|
380
826
|
return doc;
|
|
381
827
|
}
|
|
382
828
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
829
|
+
/**
|
|
830
|
+
* The fields of the flat document of an item, or undefined when only JSON text keeps it: an item
|
|
831
|
+
* that is not a plain object, an `_id` other than its id, a top-level key of the storage or with
|
|
832
|
+
* `$`, a key `__proto__`, a class instance other than `Date`. It throws a `TypeError` for a flat
|
|
833
|
+
* item that a document cannot keep: a partition field with another value than the partition, or
|
|
834
|
+
* an object that MongoDB gives back as a `DBRef`.
|
|
835
|
+
*/
|
|
836
|
+
private flatOf(id: string, value: T): FlatItem | undefined {
|
|
837
|
+
if (!isPlainObject(value)) return undefined;
|
|
838
|
+
if ("_id" in value && value["_id"] !== id) return undefined;
|
|
839
|
+
const flat: FlatItem = { keepsId: "_id" in value, kept: [], fields: [] };
|
|
840
|
+
const problems: string[] = [];
|
|
841
|
+
for (const [key, field] of Object.entries(value)) {
|
|
842
|
+
if (key === "_id") continue;
|
|
843
|
+
if (key.startsWith("$") || key === "__proto__" || STORAGE_FIELDS.has(key)) return undefined;
|
|
844
|
+
if (this.partitionValues.has(key)) {
|
|
845
|
+
// The partition field restores it on read; another value would put the document in another partition
|
|
846
|
+
if (isDeepStrictEqual(field, this.partitionValues.get(key))) flat.kept.push(key);
|
|
847
|
+
else problems.push(`the field ${key} names the partition and has another value than the partition`);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (!isNativeValue(field, key, problems)) return undefined;
|
|
851
|
+
flat.fields.push([key, field]);
|
|
852
|
+
}
|
|
853
|
+
if (problems.length > 0) throw new TypeError(`The storage cannot keep the item ${JSON.stringify(id)}: ${problems.join("; ")}`);
|
|
854
|
+
return flat;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The id and the item of a stored document, or undefined when the document does not tell its id. */
|
|
858
|
+
private decode(doc: Document, known?: string): [string, T] | undefined {
|
|
859
|
+
const id = this.idOfDoc(doc) ?? known;
|
|
860
|
+
if (id === undefined) return undefined;
|
|
861
|
+
return [id, itemOf(doc, id, this.partitionEntries, false) as T];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The entries of stored documents, in their order. A document that does not tell its id is left out. */
|
|
865
|
+
private decodeAll(docs: readonly Document[]): Array<[string, T]> {
|
|
866
|
+
const entries: Array<[string, T]> = [];
|
|
867
|
+
for (const doc of docs) {
|
|
868
|
+
const entry = this.decode(doc);
|
|
869
|
+
if (entry) entries.push(entry);
|
|
870
|
+
}
|
|
871
|
+
return entries;
|
|
391
872
|
}
|
|
392
873
|
|
|
393
874
|
/**
|
|
@@ -467,9 +948,17 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
467
948
|
/**
|
|
468
949
|
* This method takes numbers and writes with them. On a replica set, the two are one
|
|
469
950
|
* transaction. Else the numbers stay pending until the write ends, and a late write moves its
|
|
470
|
-
* documents to new numbers.
|
|
951
|
+
* documents to new numbers. With `own` false (an adoption of a raw write, D39), a watcher of this
|
|
952
|
+
* process does not take the numbers as its own.
|
|
471
953
|
*/
|
|
472
|
-
private async withNumbers<R>(
|
|
954
|
+
private async withNumbers<R>(
|
|
955
|
+
db: Db,
|
|
956
|
+
collection: Collection<Stored>,
|
|
957
|
+
count: number,
|
|
958
|
+
write: (first: number, session?: ClientSession) => Promise<R>,
|
|
959
|
+
own = true
|
|
960
|
+
): Promise<R> {
|
|
961
|
+
const noteOwn = own ? noteOwnNumbers : (): void => undefined;
|
|
473
962
|
const client = await this.transactionClient(db);
|
|
474
963
|
if (client) {
|
|
475
964
|
const session = client.startSession();
|
|
@@ -481,6 +970,8 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
481
970
|
{ upsert: true, returnDocument: "after", session }
|
|
482
971
|
)) as unknown as Counter | null;
|
|
483
972
|
if (!counter) throw new Error(`The sequence of ${this.collectionName} is not available`);
|
|
973
|
+
// A watcher of the collection in this process skips the events of these numbers (roadmap CLI-77)
|
|
974
|
+
noteOwn(db, this.collectionName, counter.seq - count + 1, count);
|
|
484
975
|
return write(counter.seq - count + 1, session);
|
|
485
976
|
});
|
|
486
977
|
} finally {
|
|
@@ -488,6 +979,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
488
979
|
}
|
|
489
980
|
}
|
|
490
981
|
let { first } = await this.allocate(db, count);
|
|
982
|
+
noteOwn(db, this.collectionName, first, count);
|
|
491
983
|
let result: R;
|
|
492
984
|
try {
|
|
493
985
|
result = await write(first);
|
|
@@ -499,6 +991,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
499
991
|
if (moves >= MAX_MOVES) throw new Error(`A write of ${this.collectionName} stayed late after ${MAX_MOVES} moves to new numbers`);
|
|
500
992
|
// Late: a reader can have passed these numbers. The write takes new ones.
|
|
501
993
|
const next = (await this.allocate(db, count)).first;
|
|
994
|
+
noteOwn(db, this.collectionName, next, count);
|
|
502
995
|
await this.move(collection, first, count, next);
|
|
503
996
|
first = next;
|
|
504
997
|
}
|
|
@@ -543,6 +1036,31 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
543
1036
|
return `floor${SEPARATOR}${this.collectionName}${SEPARATOR}${this.partition ?? ""}`;
|
|
544
1037
|
}
|
|
545
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* This method writes the floor document of the partition once for this storage, before its first
|
|
1041
|
+
* write (roadmap O66). Then `knownPartitions()` of each process finds the partition, also without
|
|
1042
|
+
* a prune, and a gap of `watchStorageChanges()` sends it a reset hint.
|
|
1043
|
+
*
|
|
1044
|
+
* - The floor `0` is neutral: `getChangesSince` gives a full result only for a version below the
|
|
1045
|
+
* floor, and a version is never below `0`. A missing floor document also reads as `0`. So the
|
|
1046
|
+
* document changes no result of the change log.
|
|
1047
|
+
* - `$setOnInsert` writes only a new document: a floor of a prune (`$max`) stays as it is.
|
|
1048
|
+
* - A duplicate key (another process inserted the same document first) is a success.
|
|
1049
|
+
* - The upsert runs in the queue of the writes, before the task, so never in the transaction of
|
|
1050
|
+
* `withNumbers()`: it adds no write conflict to the transaction of the user, and a transaction
|
|
1051
|
+
* that tries again does not write it again.
|
|
1052
|
+
* - A failure does not fail the write of the user. The flag stays false, so the next write tries again.
|
|
1053
|
+
*/
|
|
1054
|
+
private async noteFloor(db: Db): Promise<void> {
|
|
1055
|
+
if (this.floorNoted) return;
|
|
1056
|
+
try {
|
|
1057
|
+
await db.collection<CounterDoc>(this.countersName).updateOne({ _id: this.floorId() }, { $setOnInsert: { floor: 0 } }, { upsert: true });
|
|
1058
|
+
this.floorNoted = true;
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (isDuplicateKey(error)) this.floorNoted = true;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
546
1064
|
/** This method drops the oldest tombstones of the partition over the limit, and keeps the floor of the log. */
|
|
547
1065
|
private async pruneTombstones(db: Db, items: Collection<Stored>): Promise<void> {
|
|
548
1066
|
const count = await items.countDocuments({ ...this.partitionFilter(), [TOMB]: true } as Filter<Stored>);
|
|
@@ -554,15 +1072,27 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
554
1072
|
.limit(excess)
|
|
555
1073
|
.toArray();
|
|
556
1074
|
if (oldest.length === 0) return;
|
|
1075
|
+
// A watcher of this process reads the delete of a pruned tombstone without the image before the change (roadmap CLI-77)
|
|
1076
|
+
notePrunedIds(db, this.collectionName, oldest.map((doc) => doc._id));
|
|
557
1077
|
await items.deleteMany({ _id: { $in: oldest.map((doc) => doc._id) } } as Filter<Stored>);
|
|
558
1078
|
const floor = Math.max(...oldest.map((doc) => Number(doc[SEQ])));
|
|
559
1079
|
await db.collection<CounterDoc>(this.countersName).updateOne({ _id: this.floorId() }, { $max: { floor } }, { upsert: true });
|
|
560
1080
|
}
|
|
561
1081
|
|
|
1082
|
+
/** The ids of stored documents, in their order. A document that does not tell its id is left out. */
|
|
1083
|
+
private idsOf(docs: readonly Document[]): string[] {
|
|
1084
|
+
const ids: string[] = [];
|
|
1085
|
+
for (const doc of docs) {
|
|
1086
|
+
const id = this.idOfDoc(doc);
|
|
1087
|
+
if (id !== undefined) ids.push(id);
|
|
1088
|
+
}
|
|
1089
|
+
return ids;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
562
1092
|
/** The items of the partition, in the order of the entries. */
|
|
563
1093
|
private async liveDocs(extra?: Document): Promise<Document[]> {
|
|
564
1094
|
const { items } = await this.collection();
|
|
565
|
-
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1 }).toArray();
|
|
1095
|
+
return items.find(this.liveFilter(extra)).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
566
1096
|
}
|
|
567
1097
|
|
|
568
1098
|
/** The places (`_ord`) of the items of some ids that the partition has. */
|
|
@@ -570,7 +1100,12 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
570
1100
|
const docs = await collection
|
|
571
1101
|
.find(this.liveFilter({ _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } }), { projection: { [KEY]: 1, [ORD]: 1 } })
|
|
572
1102
|
.toArray();
|
|
573
|
-
|
|
1103
|
+
const places = new Map<string, number>();
|
|
1104
|
+
for (const doc of docs) {
|
|
1105
|
+
const id = this.idOfDoc(doc);
|
|
1106
|
+
if (id !== undefined) places.set(id, this.placeOf(doc));
|
|
1107
|
+
}
|
|
1108
|
+
return places;
|
|
574
1109
|
}
|
|
575
1110
|
|
|
576
1111
|
// ===========================================================================
|
|
@@ -580,21 +1115,21 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
580
1115
|
async get(id: string): Promise<T | undefined> {
|
|
581
1116
|
const { items } = await this.collection();
|
|
582
1117
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
583
|
-
return doc ? this.decode(doc)[1] : undefined;
|
|
1118
|
+
return doc ? this.decode(doc, id)?.[1] : undefined;
|
|
584
1119
|
}
|
|
585
1120
|
|
|
586
1121
|
async getAll(): Promise<T[]> {
|
|
587
|
-
return (await this.liveDocs()).map((
|
|
1122
|
+
return this.decodeAll(await this.liveDocs()).map(([, value]) => value);
|
|
588
1123
|
}
|
|
589
1124
|
|
|
590
1125
|
async getEntries(): Promise<Array<[string, T]>> {
|
|
591
|
-
return (await this.liveDocs())
|
|
1126
|
+
return this.decodeAll(await this.liveDocs());
|
|
592
1127
|
}
|
|
593
1128
|
|
|
594
1129
|
async getIds(): Promise<string[]> {
|
|
595
1130
|
const { items } = await this.collection();
|
|
596
|
-
const docs = await items.find(this.liveFilter(), { projection: { [KEY]: 1 } }).sort({ [ORD]: 1 }).toArray();
|
|
597
|
-
return
|
|
1131
|
+
const docs = await items.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
1132
|
+
return this.idsOf(docs);
|
|
598
1133
|
}
|
|
599
1134
|
|
|
600
1135
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
@@ -605,9 +1140,9 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
605
1140
|
const rule = policy ?? this.options.queryPolicy;
|
|
606
1141
|
// The reference checks the selector and the options first, without a read of the database
|
|
607
1142
|
queryEntries([], selector, options, rule);
|
|
608
|
-
const filter = selectorPrefilter(selector);
|
|
1143
|
+
const filter = selectorPrefilter(selector, { storedId: (id) => this.storedId(id), storageFields: this.reserved });
|
|
609
1144
|
const docs = await this.liveDocs(filter ? { $or: [{ [JSON_FIELD]: { $exists: true } }, filter] } : undefined);
|
|
610
|
-
return queryEntries(
|
|
1145
|
+
return queryEntries(this.decodeAll(docs), selector, options, rule) as Array<[string, T]>;
|
|
611
1146
|
}
|
|
612
1147
|
|
|
613
1148
|
async has(id: string): Promise<boolean> {
|
|
@@ -617,7 +1152,12 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
617
1152
|
|
|
618
1153
|
async size(): Promise<number> {
|
|
619
1154
|
const { items } = await this.collection();
|
|
620
|
-
|
|
1155
|
+
// A document with `_key` tells its id. A document from outside the storage counts only when `idOf` names it, as in getAll
|
|
1156
|
+
const [all, outside] = await Promise.all([
|
|
1157
|
+
items.countDocuments(this.liveFilter()),
|
|
1158
|
+
items.find(this.liveFilter({ [KEY]: { $exists: false } }), { projection: { _id: 1 } }).toArray(),
|
|
1159
|
+
]);
|
|
1160
|
+
return all - (outside.length - this.idsOf(outside).length);
|
|
621
1161
|
}
|
|
622
1162
|
|
|
623
1163
|
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
@@ -628,7 +1168,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
628
1168
|
}
|
|
629
1169
|
const { items } = await this.collection();
|
|
630
1170
|
const docs = await items.find(this.liveFilter({ _id: { $in: ids.map((id) => this.storedId(id)) } })).toArray();
|
|
631
|
-
const byId = new Map(
|
|
1171
|
+
const byId = new Map(this.decodeAll(docs));
|
|
632
1172
|
// The order of the ids of the call
|
|
633
1173
|
for (const id of ids) if (byId.has(id)) result.set(id, byId.get(id)!);
|
|
634
1174
|
return result;
|
|
@@ -654,11 +1194,16 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
654
1194
|
return cutResults(results, maxResults, bounded.limited);
|
|
655
1195
|
}
|
|
656
1196
|
|
|
657
|
-
/**
|
|
1197
|
+
/**
|
|
1198
|
+
* The version of an item: the sequence number of its last write, or undefined for a missing id.
|
|
1199
|
+
* An item without a write through the storage (no `_seq`) has the version `0`. It is the version
|
|
1200
|
+
* of `versionOfStored()` of the stored document (roadmap CLI-77): the two share one rule.
|
|
1201
|
+
*/
|
|
658
1202
|
async versionOf(id: string): Promise<string | undefined> {
|
|
659
1203
|
const { items } = await this.collection();
|
|
660
1204
|
const doc = await items.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { [SEQ]: 1 } });
|
|
661
|
-
|
|
1205
|
+
// The filter finds the document by the stored `_id` of the id in the partition: it is the item
|
|
1206
|
+
return doc ? seqVersion(doc) : undefined;
|
|
662
1207
|
}
|
|
663
1208
|
|
|
664
1209
|
async getChangesSince(since?: string): Promise<StorageChanges<T>> {
|
|
@@ -669,7 +1214,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
669
1214
|
const full = async (): Promise<StorageChanges<T>> => ({
|
|
670
1215
|
version,
|
|
671
1216
|
full: true,
|
|
672
|
-
changed: (await items.find(this.liveFilter()).sort({ [ORD]: 1 }).toArray())
|
|
1217
|
+
changed: this.decodeAll(await items.find(this.liveFilter()).sort({ [ORD]: 1, _id: 1 }).toArray()),
|
|
673
1218
|
deleted: [],
|
|
674
1219
|
});
|
|
675
1220
|
const match = since === undefined ? null : /^([^.]+)\.(\d+)$/.exec(since);
|
|
@@ -686,8 +1231,10 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
686
1231
|
const changed: Array<[string, T]> = [];
|
|
687
1232
|
const deleted: string[] = [];
|
|
688
1233
|
for (const doc of docs) {
|
|
689
|
-
|
|
690
|
-
|
|
1234
|
+
const id = this.idOfDoc(doc);
|
|
1235
|
+
if (id === undefined) continue;
|
|
1236
|
+
if (doc[TOMB] === true) deleted.push(id);
|
|
1237
|
+
else changed.push(this.decode(doc, id)!);
|
|
691
1238
|
}
|
|
692
1239
|
return { version, full: false, changed, deleted };
|
|
693
1240
|
});
|
|
@@ -699,7 +1246,11 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
699
1246
|
|
|
700
1247
|
private async write<R>(task: (db: Db, items: Collection<Stored>) => Promise<R>): Promise<R> {
|
|
701
1248
|
const { db, items } = await this.collection();
|
|
702
|
-
return enqueue(db, this.collectionName, () =>
|
|
1249
|
+
return enqueue(db, this.collectionName, async () => {
|
|
1250
|
+
// The floor document of the partition, outside each transaction of the task (roadmap O66)
|
|
1251
|
+
await this.noteFloor(db);
|
|
1252
|
+
return task(db, items);
|
|
1253
|
+
});
|
|
703
1254
|
}
|
|
704
1255
|
|
|
705
1256
|
async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
|
|
@@ -719,6 +1270,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
719
1270
|
// The ids and the encoding are checked before a number of the sequence goes
|
|
720
1271
|
for (const [id, value] of items) {
|
|
721
1272
|
this.storedId(id);
|
|
1273
|
+
this.flatOf(id, value);
|
|
722
1274
|
JSON.stringify(value);
|
|
723
1275
|
}
|
|
724
1276
|
await this.write(async (db, collection) => {
|
|
@@ -749,9 +1301,11 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
749
1301
|
return this.write(async (db, collection) => {
|
|
750
1302
|
const doc = await collection.findOne(this.liveFilter({ _id: this.storedId(id) }));
|
|
751
1303
|
if (!doc) return undefined;
|
|
752
|
-
const next = applyPatch(this.decode(doc)[1], fields) as T;
|
|
1304
|
+
const next = applyPatch(this.decode(doc, id)![1], fields) as T;
|
|
1305
|
+
// The encoding is checked before a number of the sequence goes
|
|
1306
|
+
this.flatOf(id, next);
|
|
753
1307
|
await this.withNumbers(db, collection, 1, (first, session) =>
|
|
754
|
-
collection.replaceOne(this.writeFilter(id), this.encode(id, next, first,
|
|
1308
|
+
collection.replaceOne(this.writeFilter(id), this.encode(id, next, first, this.placeOf(doc)), { upsert: true, ...(session ? { session } : {}) })
|
|
755
1309
|
);
|
|
756
1310
|
return next;
|
|
757
1311
|
});
|
|
@@ -787,7 +1341,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
787
1341
|
.find(this.liveFilter({ _id: { $in: unique.map((id) => this.storedId(id)) } }), { projection: { _id: 1, [KEY]: 1 } })
|
|
788
1342
|
.toArray();
|
|
789
1343
|
if (live.length === 0) return 0;
|
|
790
|
-
const liveIds = new Set(
|
|
1344
|
+
const liveIds = new Set(this.idsOf(live));
|
|
791
1345
|
const deleted = unique.filter((id) => liveIds.has(id));
|
|
792
1346
|
await this.tombstone(db, collection, deleted);
|
|
793
1347
|
return deleted.length;
|
|
@@ -796,13 +1350,10 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
796
1350
|
|
|
797
1351
|
async clear(_options?: StorageWriteOptions): Promise<void> {
|
|
798
1352
|
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
|
-
);
|
|
1353
|
+
const live = await collection.find(this.liveFilter(), { projection: { _id: 1, [KEY]: 1 } }).sort({ [ORD]: 1, _id: 1 }).toArray();
|
|
1354
|
+
const ids = this.idsOf(live);
|
|
1355
|
+
if (ids.length === 0) return;
|
|
1356
|
+
await this.tombstone(db, collection, ids);
|
|
806
1357
|
});
|
|
807
1358
|
}
|
|
808
1359
|
|
|
@@ -815,7 +1366,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
815
1366
|
const guards = await this.checkVersions(collection, expected, session);
|
|
816
1367
|
const operations: AnyBulkWriteOperation<Stored>[] = ids.map((id, index) => {
|
|
817
1368
|
const tomb: Stored = { _id: this.storedId(id), [KEY]: id, [SEQ]: first + index, [TOMB]: true };
|
|
818
|
-
|
|
1369
|
+
this.putPartition(tomb);
|
|
819
1370
|
return { replaceOne: { filter: { ...this.writeFilter(id), ...guards.get(id) }, replacement: tomb } };
|
|
820
1371
|
});
|
|
821
1372
|
const result = await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
|
|
@@ -825,6 +1376,205 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
825
1376
|
await this.pruneTombstones(db, collection);
|
|
826
1377
|
}
|
|
827
1378
|
|
|
1379
|
+
// ===========================================================================
|
|
1380
|
+
// The raw writes (D39, roadmap CLI-77)
|
|
1381
|
+
// ===========================================================================
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* This method adopts a raw write: one conditional write through the path of each write gives the
|
|
1385
|
+
* document a new `_seq` (see `RawStorageWrite`). It is for `watchStorageChanges()`, and it is no
|
|
1386
|
+
* general setter of `_seq`.
|
|
1387
|
+
*/
|
|
1388
|
+
private async adoptRaw(raw: RawStorageWrite): Promise<RawAdoption> {
|
|
1389
|
+
const [result] = await this.adoptRawAll([raw]);
|
|
1390
|
+
if (result === undefined || typeof result !== "string") throw (result as { error: unknown } | undefined)?.error;
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* This method adopts several raw writes. The writes of each partition take one block of numbers
|
|
1396
|
+
* through the path of each write. A number of a write that does not win stays unused.
|
|
1397
|
+
*/
|
|
1398
|
+
private async adoptRawAll(raws: readonly RawStorageWrite[]): Promise<RawAdoptionResult[]> {
|
|
1399
|
+
const results: RawAdoptionResult[] = raws.map(() => "unknown");
|
|
1400
|
+
const groups = new Map<MongoDocumentStorage<T>, Array<{ index: number; raw: RawStorageWrite; id: string }>>();
|
|
1401
|
+
raws.forEach((raw, index) => {
|
|
1402
|
+
const target = this.adoptionTarget(raw);
|
|
1403
|
+
if (!target) return;
|
|
1404
|
+
let group = groups.get(target.storage);
|
|
1405
|
+
if (!group) groups.set(target.storage, (group = []));
|
|
1406
|
+
group.push({ index, raw, id: target.id });
|
|
1407
|
+
});
|
|
1408
|
+
for (const [storage, group] of groups) {
|
|
1409
|
+
try {
|
|
1410
|
+
const done = await storage.adoptGroup(group);
|
|
1411
|
+
group.forEach((entry, position) => (results[entry.index] = done[position] ?? "lost"));
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
for (const entry of group) results[entry.index] = { error };
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return results;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** The adoption of raw writes of this partition, with one block of numbers. */
|
|
1420
|
+
private async adoptGroup(group: ReadonlyArray<{ raw: RawStorageWrite; id: string }>): Promise<RawAdoption[]> {
|
|
1421
|
+
return this.write(async (db, collection) => {
|
|
1422
|
+
const results = await this.withNumbers(
|
|
1423
|
+
db,
|
|
1424
|
+
collection,
|
|
1425
|
+
group.length,
|
|
1426
|
+
async (first, session): Promise<RawAdoption[]> => {
|
|
1427
|
+
const out: RawAdoption[] = [];
|
|
1428
|
+
for (const [position, { raw, id }] of group.entries()) {
|
|
1429
|
+
const seq = first + position;
|
|
1430
|
+
out.push(raw.after === undefined ? await this.adoptDelete(collection, raw, id, seq, session) : await this.adoptWrite(collection, raw, raw.after, id, seq, session));
|
|
1431
|
+
}
|
|
1432
|
+
return out;
|
|
1433
|
+
},
|
|
1434
|
+
false
|
|
1435
|
+
);
|
|
1436
|
+
if (results.some((result, position) => result === "adopted" && group[position]!.raw.after === undefined)) await this.pruneTombstones(db, collection);
|
|
1437
|
+
return results;
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** A raw hard delete: a tombstone with the id and the partition of the image before the change. */
|
|
1442
|
+
private async adoptDelete(collection: Collection<Stored>, raw: RawStorageWrite, id: string, seq: number, session: ClientSession | undefined): Promise<RawAdoption> {
|
|
1443
|
+
// In a transaction, a duplicate key would end the transaction: the read comes first
|
|
1444
|
+
if (session && (await collection.findOne({ _id: raw.storedId } as Filter<Stored>, { projection: { _id: 1 }, session }))) return "lost";
|
|
1445
|
+
const tomb: Stored = { _id: raw.storedId, [KEY]: id, [SEQ]: seq, [TOMB]: true };
|
|
1446
|
+
this.putPartition(tomb);
|
|
1447
|
+
try {
|
|
1448
|
+
await collection.insertOne(tomb, session ? { session } : {});
|
|
1449
|
+
return "adopted";
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
// A raw insert of the same _id came after the delete: that insert is the newer state
|
|
1452
|
+
if (isDuplicateKey(error)) return "lost";
|
|
1453
|
+
throw error;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** A raw insert, replace or update: one conditional write on the exact state of the event. */
|
|
1458
|
+
private async adoptWrite(collection: Collection<Stored>, raw: RawStorageWrite, after: Document, id: string, seq: number, session: ClientSession | undefined): Promise<RawAdoption> {
|
|
1459
|
+
const fields = this.restoredFields(raw, after, id);
|
|
1460
|
+
// The exact state of the event: the same `_seq` (or none), and no field that the adoption sets
|
|
1461
|
+
const filter: Document = { _id: raw.storedId, [SEQ]: raw.seq ?? { $exists: false } };
|
|
1462
|
+
for (const name of Object.keys(fields)) filter[name] = { $exists: false };
|
|
1463
|
+
const result = await collection.updateOne(filter as Filter<Stored>, { $set: { ...fields, [SEQ]: seq } }, session ? { session } : {});
|
|
1464
|
+
return result.matchedCount === 1 ? "adopted" : "lost";
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** One page of the documents without `_seq`, in the order of `_id` (the sweep of `watchStorageChanges()`). */
|
|
1468
|
+
private async rawDocuments(after: { readonly id: unknown } | undefined, limit: number): Promise<Document[]> {
|
|
1469
|
+
const db = await this.database();
|
|
1470
|
+
// The scan walks the index of _id from the last _id: `min` bounds the scan, in the order of the
|
|
1471
|
+
// BSON types. `min` includes that _id, so the page skips it. No index can find the documents
|
|
1472
|
+
// without a field (a partial index cannot express $exists: false): the scan reads each document.
|
|
1473
|
+
let cursor = db.collection(this.collectionName).find({ [SEQ]: { $exists: false } }).hint({ _id: 1 });
|
|
1474
|
+
if (after) cursor = cursor.min({ _id: after.id });
|
|
1475
|
+
const docs = await cursor.limit(after ? limit + 1 : limit).toArray();
|
|
1476
|
+
if (after && docs.length > 0 && sameBson(docs[0]!["_id"], after.id)) docs.shift();
|
|
1477
|
+
return docs.slice(0, limit);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** The stored documents of these stored `_id`s, for the adoptions that `watchStorageChanges()` does again. */
|
|
1481
|
+
private async storedDocuments(ids: readonly unknown[]): Promise<Document[]> {
|
|
1482
|
+
const db = await this.database();
|
|
1483
|
+
const filter: Document = { _id: { $in: [...ids] } };
|
|
1484
|
+
return db.collection(this.collectionName).find(filter).toArray();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* The storage of the partition of a raw write and the id of its document, or undefined when
|
|
1489
|
+
* neither the documents of the event nor the stored `_id` tell them. The partition comes from
|
|
1490
|
+
* `form.partitionOf()` of the image before the change, else of the document after the write,
|
|
1491
|
+
* else of the stored `_id` (the order of `storageChangeOf()`). The document must not have a
|
|
1492
|
+
* partition field with another value.
|
|
1493
|
+
*/
|
|
1494
|
+
private adoptionTarget(raw: RawStorageWrite): { storage: MongoDocumentStorage<T>; id: string } | undefined {
|
|
1495
|
+
const doc = raw.after ?? raw.before;
|
|
1496
|
+
if (!doc) return undefined;
|
|
1497
|
+
const places = new Set<string | undefined>();
|
|
1498
|
+
for (const source of [raw.before, raw.after, { _id: raw.storedId }]) if (isPlainObject(source)) places.add(this.form.partitionOf(source));
|
|
1499
|
+
for (const place of places) {
|
|
1500
|
+
if (place !== this.partition && (this.partition !== undefined || place === undefined)) continue;
|
|
1501
|
+
let storage: MongoDocumentStorage<T>;
|
|
1502
|
+
try {
|
|
1503
|
+
storage = place === this.partition ? this : this.forPartition(place as string);
|
|
1504
|
+
} catch {
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (!storage.holds(doc)) continue;
|
|
1508
|
+
const fromId = this.form.idOf(raw.storedId, place);
|
|
1509
|
+
const key = raw.after?.[KEY] ?? raw.before?.[KEY];
|
|
1510
|
+
const id = typeof fromId === "string" ? fromId : typeof key === "string" ? key : undefined;
|
|
1511
|
+
if (id === undefined || id.includes(SEPARATOR)) continue;
|
|
1512
|
+
return { storage, id };
|
|
1513
|
+
}
|
|
1514
|
+
return undefined;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** True when each partition field of a document has the value of this partition, or is absent (a raw replace can drop it). */
|
|
1518
|
+
private holds(doc: Document): boolean {
|
|
1519
|
+
for (const [name, value] of this.partitionEntries) {
|
|
1520
|
+
if (!(name in doc)) continue;
|
|
1521
|
+
if (value === undefined || !isDeepStrictEqual(doc[name], value)) return false;
|
|
1522
|
+
}
|
|
1523
|
+
return true;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* The storage fields and the partition fields that a raw write took from the document, for the
|
|
1528
|
+
* adoption to set again. No field of the item is in it.
|
|
1529
|
+
*
|
|
1530
|
+
* - The partition fields of this partition that the document does not have.
|
|
1531
|
+
* - `_key` (with `_kid`) of the image before the change. Without the image, `_key` only when
|
|
1532
|
+
* `form.idOf()` does not tell the id, with `_kid`: the item keeps its `_id`, as a document from
|
|
1533
|
+
* outside the storage.
|
|
1534
|
+
* - `_ord` of the image before the change, else `0`, the place of a document without `_ord`
|
|
1535
|
+
* (the rule of the old documents).
|
|
1536
|
+
* - The names of `_kpf` of the image before the change that the document has with the value of
|
|
1537
|
+
* the partition.
|
|
1538
|
+
*/
|
|
1539
|
+
private restoredFields(raw: RawStorageWrite, after: Document, id: string): Document {
|
|
1540
|
+
const before = raw.before;
|
|
1541
|
+
const fields: Document = {};
|
|
1542
|
+
for (const [name, value] of this.partitionEntries) if (value !== undefined && !(name in after)) fields[name] = value;
|
|
1543
|
+
if (after[KEY] === undefined) {
|
|
1544
|
+
if (typeof before?.[KEY] === "string") {
|
|
1545
|
+
fields[KEY] = before[KEY];
|
|
1546
|
+
if (before[KEEP_ID] === true && after[KEEP_ID] === undefined) fields[KEEP_ID] = true;
|
|
1547
|
+
} else if (this.form.idOf(raw.storedId, this.partition) !== id) {
|
|
1548
|
+
fields[KEY] = id;
|
|
1549
|
+
if (after[KEEP_ID] === undefined) fields[KEEP_ID] = true;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
if (after[ORD] === undefined) fields[ORD] = typeof before?.[ORD] === "number" ? before[ORD] : 0;
|
|
1553
|
+
const kept = before?.[KEEP_PART];
|
|
1554
|
+
if (after[KEEP_PART] === undefined && Array.isArray(kept)) {
|
|
1555
|
+
const names = kept.filter((name): name is string => typeof name === "string" && this.partitionValues.has(name) && isDeepStrictEqual(after[name], this.partitionValues.get(name)));
|
|
1556
|
+
if (names.length > 0) fields[KEEP_PART] = names;
|
|
1557
|
+
}
|
|
1558
|
+
return fields;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** The partitions that the storage knows: the floor documents of the counters (of the first writes and of the prunes), and the storages of `forPartition()`. */
|
|
1562
|
+
private async knownPartitions(): Promise<Array<string | undefined>> {
|
|
1563
|
+
const db = await this.database();
|
|
1564
|
+
const prefix = `floor${SEPARATOR}${this.collectionName}${SEPARATOR}`;
|
|
1565
|
+
const floors = await db
|
|
1566
|
+
.collection<CounterDoc>(this.countersName)
|
|
1567
|
+
.find({ _id: { $regex: `^${escapeRegex(prefix)}` } } as Filter<CounterDoc>, { projection: { _id: 1 } })
|
|
1568
|
+
.limit(MAX_KNOWN_PARTITIONS)
|
|
1569
|
+
.toArray();
|
|
1570
|
+
const found = new Set<string | undefined>(this.partitions.keys());
|
|
1571
|
+
for (const floor of floors) {
|
|
1572
|
+
const rest = String(floor._id).slice(prefix.length);
|
|
1573
|
+
found.add(rest === "" ? undefined : rest);
|
|
1574
|
+
}
|
|
1575
|
+
return [...found];
|
|
1576
|
+
}
|
|
1577
|
+
|
|
828
1578
|
/**
|
|
829
1579
|
* The preconditions of a write (roadmap CLI-16), after a check of their ids. A storage without
|
|
830
1580
|
* `versionOf` throws a `TypeError` before the write starts.
|
|
@@ -841,12 +1591,18 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
841
1591
|
const docs = await collection
|
|
842
1592
|
.find({ ...this.partitionFilter(), _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } } as Filter<Stored>, session ? { session } : {})
|
|
843
1593
|
.toArray();
|
|
844
|
-
|
|
1594
|
+
const byId = new Map<string, Document>();
|
|
1595
|
+
for (const doc of docs) {
|
|
1596
|
+
const id = this.idOfDoc(doc);
|
|
1597
|
+
if (id !== undefined) byId.set(id, doc);
|
|
1598
|
+
}
|
|
1599
|
+
return byId;
|
|
845
1600
|
}
|
|
846
1601
|
|
|
847
1602
|
/** The current item of a stored document: no item for a tombstone or a missing document. */
|
|
848
1603
|
private currentOf(doc: Document | undefined): CurrentItem<T> {
|
|
849
|
-
|
|
1604
|
+
const entry = doc && doc[TOMB] !== true ? this.decode(doc) : undefined;
|
|
1605
|
+
return entry ? { found: true, value: entry[1] } : { found: false };
|
|
850
1606
|
}
|
|
851
1607
|
|
|
852
1608
|
/**
|
|
@@ -868,7 +1624,8 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
|
|
|
868
1624
|
if (session) return guards;
|
|
869
1625
|
for (const [id] of expected) {
|
|
870
1626
|
const doc = docs.get(id);
|
|
871
|
-
|
|
1627
|
+
// A document without `_seq` (not written through the storage yet) keeps that state until the write
|
|
1628
|
+
guards.set(id, doc && typeof doc[SEQ] === "number" ? { [SEQ]: doc[SEQ] } : { [SEQ]: { $exists: false } });
|
|
872
1629
|
}
|
|
873
1630
|
return guards;
|
|
874
1631
|
}
|