liwi-mongo 13.4.0 → 14.0.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/README.md +27 -2
- package/dist/definitions/MongoCursor.d.ts.map +1 -1
- package/dist/definitions/MongoQuerySingleItem.d.ts +1 -0
- package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
- package/dist/definitions/MongoQuerySingleItem.test.d.ts +2 -0
- package/dist/definitions/MongoQuerySingleItem.test.d.ts.map +1 -0
- package/dist/definitions/MongoStore.d.ts +18 -4
- package/dist/definitions/MongoStore.d.ts.map +1 -1
- package/dist/definitions/SubscribeStore.integration.test.d.ts +2 -0
- package/dist/definitions/SubscribeStore.integration.test.d.ts.map +1 -0
- package/dist/definitions/writes/createReplacePipeline.d.ts +10 -0
- package/dist/definitions/writes/createReplacePipeline.d.ts.map +1 -0
- package/dist/definitions/writes/stampPartialUpdate.d.ts +9 -0
- package/dist/definitions/writes/stampPartialUpdate.d.ts.map +1 -0
- package/dist/definitions/writes/stampPartialUpdate.test.d.ts +2 -0
- package/dist/definitions/writes/stampPartialUpdate.test.d.ts.map +1 -0
- package/dist/index-node.mjs +205 -81
- package/dist/index-node.mjs.map +1 -1
- package/package.json +5 -5
- package/src/MongoCursor.ts +2 -4
- package/src/MongoQuerySingleItem.test.ts +162 -0
- package/src/MongoQuerySingleItem.ts +56 -50
- package/src/MongoStore.test.ts +250 -0
- package/src/MongoStore.ts +122 -26
- package/src/SubscribeStore.integration.test.ts +414 -0
- package/src/indexes/diffIndexes.test.ts +2 -2
- package/src/indexes/normalizeIndex.test.ts +2 -2
- package/src/indexes/normalizeIndex.ts +2 -2
- package/src/writes/createReplacePipeline.ts +23 -0
- package/src/writes/stampPartialUpdate.test.ts +93 -0
- package/src/writes/stampPartialUpdate.ts +84 -0
package/src/MongoStore.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
import { NotFoundError, ProtectedFieldUpdateError } from "liwi-store";
|
|
1
2
|
import type {
|
|
2
3
|
AllowedKeyValue,
|
|
3
4
|
CreateQueryOptions,
|
|
4
5
|
Criteria,
|
|
6
|
+
DeleteResult,
|
|
5
7
|
Fields,
|
|
6
8
|
OptionalBaseModelKeysForInsert,
|
|
9
|
+
PartialUpdateOptions,
|
|
10
|
+
PartialUpdateResult,
|
|
7
11
|
QueryParams,
|
|
8
12
|
Sort,
|
|
9
13
|
SubscribableStore,
|
|
10
14
|
Update,
|
|
11
15
|
UpsertPartialObject,
|
|
12
16
|
UpsertResult,
|
|
17
|
+
WriteResult,
|
|
13
18
|
} from "liwi-store";
|
|
14
19
|
import type {
|
|
15
20
|
Collection,
|
|
@@ -38,6 +43,20 @@ import type {
|
|
|
38
43
|
MongoIndexSyncResult,
|
|
39
44
|
SyncIndexesOptions,
|
|
40
45
|
} from "./indexes/types.ts";
|
|
46
|
+
import { createReplacePipeline } from "./writes/createReplacePipeline.ts";
|
|
47
|
+
import { stampPartialUpdate } from "./writes/stampPartialUpdate.ts";
|
|
48
|
+
|
|
49
|
+
const resolveWriteTime = (options?: PartialUpdateOptions): Date | undefined =>
|
|
50
|
+
options?.touchUpdated === false ? undefined : new Date();
|
|
51
|
+
|
|
52
|
+
// the driver stores `undefined` as `null` (`ignoreUndefined` is off)
|
|
53
|
+
const toStoredValues = <T extends object>(values: T): T =>
|
|
54
|
+
Object.fromEntries(
|
|
55
|
+
Object.entries(values).map(([field, value]: [string, unknown]) => [
|
|
56
|
+
field,
|
|
57
|
+
value === undefined ? null : value,
|
|
58
|
+
]),
|
|
59
|
+
) as T;
|
|
41
60
|
|
|
42
61
|
export type MongoUpsertResult<
|
|
43
62
|
KeyValue extends AllowedKeyValue,
|
|
@@ -197,12 +216,42 @@ export default class MongoStore<
|
|
|
197
216
|
return object as Model;
|
|
198
217
|
}
|
|
199
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Always sets `updated` to the write time and keeps the stored `created`: the
|
|
221
|
+
* object's own `created` and `updated` are ignored. The object is not mutated.
|
|
222
|
+
*/
|
|
200
223
|
async replaceOne(object: Model): Promise<Model> {
|
|
201
|
-
|
|
224
|
+
const collection = await this.collection;
|
|
225
|
+
const next = await collection.findOneAndUpdate(
|
|
226
|
+
{ _id: object._id } as Filter<Model>,
|
|
227
|
+
createReplacePipeline({ ...object, updated: new Date() }),
|
|
228
|
+
{ returnDocument: "after" },
|
|
229
|
+
);
|
|
230
|
+
if (!next) {
|
|
231
|
+
throw new NotFoundError(`Document not found: ${String(object._id)}`);
|
|
232
|
+
}
|
|
233
|
+
return next as Model;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Same rules as `replaceOne`; `next` is derived from the atomic `prev`, no read back. */
|
|
237
|
+
async replaceOneWithInfo(
|
|
238
|
+
object: Model,
|
|
239
|
+
): Promise<WriteResult<Model> | undefined> {
|
|
240
|
+
const replacement: Model = { ...object, updated: new Date() };
|
|
202
241
|
|
|
203
242
|
const collection = await this.collection;
|
|
204
|
-
await collection.
|
|
205
|
-
|
|
243
|
+
const prev = await collection.findOneAndUpdate(
|
|
244
|
+
{ _id: object._id } as Filter<Model>,
|
|
245
|
+
createReplacePipeline(replacement),
|
|
246
|
+
{ returnDocument: "before" },
|
|
247
|
+
);
|
|
248
|
+
if (!prev) return undefined;
|
|
249
|
+
|
|
250
|
+
const storedCreated = prev.created as Date | null | undefined;
|
|
251
|
+
return {
|
|
252
|
+
prev: prev as Model,
|
|
253
|
+
next: { ...replacement, created: storedCreated ?? replacement.created },
|
|
254
|
+
};
|
|
206
255
|
}
|
|
207
256
|
|
|
208
257
|
async upsertOne<
|
|
@@ -230,6 +279,12 @@ export default class MongoStore<
|
|
|
230
279
|
object: UpsertPartialObject<MongoKeyPath, KeyValue, Model, K>,
|
|
231
280
|
setOnInsertPartialObject?: Update<Model>["$setOnInsert"],
|
|
232
281
|
): Promise<MongoUpsertResult<KeyValue, Model>> {
|
|
282
|
+
if (setOnInsertPartialObject && "updated" in setOnInsertPartialObject) {
|
|
283
|
+
throw new ProtectedFieldUpdateError(
|
|
284
|
+
'Cannot use $setOnInsert on "updated": upsert always sets it with $set, pass it in the object instead',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
233
288
|
const $setOnInsert: Update<Model>["$setOnInsert"] = {
|
|
234
289
|
// @ts-expect-error -- created is Date as set in BaseModel
|
|
235
290
|
created: object.created || new Date(),
|
|
@@ -260,7 +315,7 @@ export default class MongoStore<
|
|
|
260
315
|
};
|
|
261
316
|
}
|
|
262
317
|
|
|
263
|
-
const next = { ...prev,
|
|
318
|
+
const next = { ...prev, ...toStoredValues($set) };
|
|
264
319
|
|
|
265
320
|
return {
|
|
266
321
|
resolvedAs: "updated",
|
|
@@ -278,45 +333,86 @@ export default class MongoStore<
|
|
|
278
333
|
key: KeyValue,
|
|
279
334
|
partialUpdate: Update<Model>,
|
|
280
335
|
criteria?: Criteria<Model>,
|
|
336
|
+
options?: PartialUpdateOptions,
|
|
281
337
|
): Promise<Model> {
|
|
338
|
+
const update = stampPartialUpdate(partialUpdate, resolveWriteTime(options));
|
|
282
339
|
const collection = await this.collection;
|
|
283
|
-
const
|
|
340
|
+
const next = await collection.findOneAndUpdate(
|
|
284
341
|
{ _id: key, ...criteria } as Filter<Model>,
|
|
285
|
-
|
|
342
|
+
update as UpdateFilter<Model>,
|
|
343
|
+
{ returnDocument: "after" },
|
|
286
344
|
);
|
|
287
|
-
if (!
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
345
|
+
if (!next) throw new NotFoundError(`Document not found: ${String(key)}`);
|
|
346
|
+
return next as Model;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* `prev` is read atomically with the write. `next` is read right after it,
|
|
351
|
+
* so it may already include a later concurrent write, which emits its own change.
|
|
352
|
+
* Resolves `undefined` when no document matches.
|
|
353
|
+
*/
|
|
354
|
+
async partialUpdateByKeyWithInfo(
|
|
355
|
+
key: KeyValue,
|
|
356
|
+
partialUpdate: Update<Model>,
|
|
357
|
+
criteria?: Criteria<Model>,
|
|
358
|
+
options?: PartialUpdateOptions,
|
|
359
|
+
): Promise<PartialUpdateResult<Model> | undefined> {
|
|
360
|
+
const update = stampPartialUpdate(partialUpdate, resolveWriteTime(options));
|
|
361
|
+
const collection = await this.collection;
|
|
362
|
+
const prev = await collection.findOneAndUpdate(
|
|
363
|
+
{ _id: key, ...criteria } as Filter<Model>,
|
|
364
|
+
update as UpdateFilter<Model>,
|
|
365
|
+
{ returnDocument: "before" },
|
|
366
|
+
);
|
|
367
|
+
if (!prev) return undefined;
|
|
368
|
+
|
|
369
|
+
return { prev: prev as Model, next: await this.findByKey(key) };
|
|
293
370
|
}
|
|
294
371
|
|
|
295
372
|
partialUpdateOne(
|
|
296
373
|
object: Model,
|
|
297
374
|
partialUpdate: Update<Model>,
|
|
375
|
+
options?: PartialUpdateOptions,
|
|
298
376
|
): Promise<Model> {
|
|
299
|
-
return this.partialUpdateByKey(
|
|
377
|
+
return this.partialUpdateByKey(
|
|
378
|
+
object._id,
|
|
379
|
+
partialUpdate,
|
|
380
|
+
undefined,
|
|
381
|
+
options,
|
|
382
|
+
);
|
|
300
383
|
}
|
|
301
384
|
|
|
302
|
-
|
|
385
|
+
/** One `updateMany` command; every document gets the same `updated`. */
|
|
386
|
+
async partialUpdateMany(
|
|
303
387
|
criteria: Criteria<Model>,
|
|
304
388
|
partialUpdate: Update<Model>,
|
|
389
|
+
options?: PartialUpdateOptions,
|
|
305
390
|
): Promise<void> {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
391
|
+
const update = stampPartialUpdate(partialUpdate, resolveWriteTime(options));
|
|
392
|
+
const collection = await this.collection;
|
|
393
|
+
await collection.updateMany(
|
|
394
|
+
criteria as Filter<Model>,
|
|
395
|
+
update as UpdateFilter<Model>,
|
|
396
|
+
);
|
|
312
397
|
}
|
|
313
398
|
|
|
314
|
-
deleteByKey(key: KeyValue, criteria?: Criteria<Model>): Promise<void> {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
399
|
+
async deleteByKey(key: KeyValue, criteria?: Criteria<Model>): Promise<void> {
|
|
400
|
+
const collection = await this.collection;
|
|
401
|
+
await collection.deleteOne({ _id: key, ...criteria } as Filter<Model>);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async deleteByKeyWithInfo(
|
|
405
|
+
key: KeyValue,
|
|
406
|
+
criteria?: Criteria<Model>,
|
|
407
|
+
): Promise<DeleteResult<Model> | undefined> {
|
|
408
|
+
const collection = await this.collection;
|
|
409
|
+
const prev = await collection.findOneAndDelete({
|
|
410
|
+
_id: key,
|
|
411
|
+
...criteria,
|
|
412
|
+
} as Filter<Model>);
|
|
413
|
+
if (!prev) return undefined;
|
|
414
|
+
|
|
415
|
+
return { prev: prev as Model };
|
|
320
416
|
}
|
|
321
417
|
|
|
322
418
|
deleteOne(object: Model): Promise<void> {
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { Changes } from "liwi-store";
|
|
4
|
+
import { NotFoundError, ProtectedFieldUpdateError } from "liwi-store";
|
|
5
|
+
import type { Actions } from "liwi-subscribe-store";
|
|
6
|
+
import type { MongoMemoryServer } from "mongodb-memory-server-core";
|
|
7
|
+
import type { MongoBaseModel } from "./MongoBaseModel.ts";
|
|
8
|
+
import MongoConnection from "./MongoConnection.ts";
|
|
9
|
+
import MongoStore from "./MongoStore.ts";
|
|
10
|
+
import createMongoSubscribeStore from "./createMongoSubscribeStore.ts";
|
|
11
|
+
|
|
12
|
+
interface Item extends MongoBaseModel {
|
|
13
|
+
label: string;
|
|
14
|
+
flag: boolean;
|
|
15
|
+
note?: string | null;
|
|
16
|
+
__v?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe(
|
|
20
|
+
"SubscribeStore over MongoStore (integration)",
|
|
21
|
+
{
|
|
22
|
+
skip: !process.env.LIWI_MONGO_INTEGRATION && "set LIWI_MONGO_INTEGRATION=1",
|
|
23
|
+
timeout: 120_000,
|
|
24
|
+
},
|
|
25
|
+
() => {
|
|
26
|
+
let mongod: MongoMemoryServer;
|
|
27
|
+
let connection: MongoConnection;
|
|
28
|
+
let mongoStore: MongoStore<Item>;
|
|
29
|
+
let subscribeStore: ReturnType<typeof createMongoSubscribeStore<Item>>;
|
|
30
|
+
let actions: Actions<Item>[];
|
|
31
|
+
|
|
32
|
+
before(async () => {
|
|
33
|
+
const { MongoMemoryServer: MemoryServer } =
|
|
34
|
+
await import("mongodb-memory-server-core");
|
|
35
|
+
mongod = await MemoryServer.create();
|
|
36
|
+
connection = new MongoConnection({
|
|
37
|
+
host: "127.0.0.1",
|
|
38
|
+
port: mongod.instanceInfo!.port,
|
|
39
|
+
database: "liwi-mongo-subscribe-store-test",
|
|
40
|
+
});
|
|
41
|
+
await connection.getConnection();
|
|
42
|
+
mongoStore = new MongoStore<Item>(connection, "items");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
await connection.close();
|
|
47
|
+
await mongod.stop();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
await mongoStore.deleteMany({});
|
|
52
|
+
subscribeStore = createMongoSubscribeStore(mongoStore);
|
|
53
|
+
await subscribeStore.insertOne({ _id: "1", label: "a", flag: true });
|
|
54
|
+
actions = [];
|
|
55
|
+
subscribeStore.subscribe((action) => actions.push(action));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const loadStored = async (): Promise<Item> =>
|
|
59
|
+
(await mongoStore.findByKey("1"))!;
|
|
60
|
+
|
|
61
|
+
it("emits the stored prev on partialUpdateOne of a mutated copy", async () => {
|
|
62
|
+
const stored = await loadStored();
|
|
63
|
+
const copy: Item = { ...stored, label: "mutated locally" };
|
|
64
|
+
|
|
65
|
+
const next = await subscribeStore.partialUpdateOne(copy, {
|
|
66
|
+
$set: { flag: false },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(next, { ...stored, flag: false, updated: next.updated });
|
|
70
|
+
assert.deepEqual(actions, [
|
|
71
|
+
{ type: "updated", changes: [[stored, next]] },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("emits the stored prev on replaceOne of a stale copy", async () => {
|
|
76
|
+
const stale = await loadStored();
|
|
77
|
+
await mongoStore.partialUpdateByKey("1", { $set: { label: "b" } });
|
|
78
|
+
const stored = await loadStored();
|
|
79
|
+
|
|
80
|
+
const next = await subscribeStore.replaceOne({ ...stale, flag: false });
|
|
81
|
+
|
|
82
|
+
assert.notEqual(next, stale);
|
|
83
|
+
assert.deepEqual(actions, [
|
|
84
|
+
{ type: "updated", changes: [[stored, next]] },
|
|
85
|
+
]);
|
|
86
|
+
assert.deepEqual(await loadStored(), next);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("emits the stored prev on deleteOne of a stale copy", async () => {
|
|
90
|
+
const stale = await loadStored();
|
|
91
|
+
await mongoStore.partialUpdateByKey("1", { $set: { label: "b" } });
|
|
92
|
+
const stored = await loadStored();
|
|
93
|
+
|
|
94
|
+
await subscribeStore.deleteOne(stale);
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(actions, [{ type: "deleted", prev: [stored] }]);
|
|
97
|
+
assert.equal(await mongoStore.findByKey("1"), undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("removes a document from a query collection when replaceOne makes it leave the criteria", async () => {
|
|
101
|
+
const received: Changes<string, Item[]>[] = [];
|
|
102
|
+
const query = subscribeStore.createQueryCollection<Item, never>({
|
|
103
|
+
criteria: { flag: true },
|
|
104
|
+
});
|
|
105
|
+
query.subscribe((error, changes) => {
|
|
106
|
+
if (error) throw error;
|
|
107
|
+
received.push(changes);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const copy = await loadStored();
|
|
111
|
+
copy.flag = false;
|
|
112
|
+
await subscribeStore.replaceOne(copy);
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(received, [[{ type: "deleted", keys: ["1"] }]]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("emits nothing for writes on a missing key", async () => {
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
subscribeStore.partialUpdateByKey("missing", { $set: { label: "x" } }),
|
|
120
|
+
NotFoundError,
|
|
121
|
+
);
|
|
122
|
+
await assert.rejects(
|
|
123
|
+
subscribeStore.replaceOne({ ...(await loadStored()), _id: "missing" }),
|
|
124
|
+
NotFoundError,
|
|
125
|
+
);
|
|
126
|
+
await subscribeStore.deleteByKey("missing");
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(actions, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("treats a key that no longer matches the criteria as missing", async () => {
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
subscribeStore.partialUpdateByKey(
|
|
134
|
+
"1",
|
|
135
|
+
{ $set: { label: "x" } },
|
|
136
|
+
{ flag: false },
|
|
137
|
+
),
|
|
138
|
+
NotFoundError,
|
|
139
|
+
);
|
|
140
|
+
await subscribeStore.deleteByKey("1", { flag: false });
|
|
141
|
+
|
|
142
|
+
const stored = await loadStored();
|
|
143
|
+
assert.deepEqual(actions, []);
|
|
144
|
+
assert.equal(stored.label, "a");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("bulk writes with a concurrent write between the key read and the writes", () => {
|
|
148
|
+
// runs `inject` right before the first per-document write, after the keys were read
|
|
149
|
+
const subscribeInjecting = (
|
|
150
|
+
inject: () => Promise<unknown>,
|
|
151
|
+
): {
|
|
152
|
+
injectingSubscribeStore: ReturnType<
|
|
153
|
+
typeof createMongoSubscribeStore<Item>
|
|
154
|
+
>;
|
|
155
|
+
emitted: Actions<Item>[];
|
|
156
|
+
} => {
|
|
157
|
+
let isInjected = false;
|
|
158
|
+
const injectOnce = async (): Promise<void> => {
|
|
159
|
+
if (isInjected) return;
|
|
160
|
+
isInjected = true;
|
|
161
|
+
await inject();
|
|
162
|
+
};
|
|
163
|
+
const injectingStore: MongoStore<Item> = Object.assign(
|
|
164
|
+
Object.create(mongoStore) as MongoStore<Item>,
|
|
165
|
+
{
|
|
166
|
+
partialUpdateByKeyWithInfo: async (
|
|
167
|
+
...args: Parameters<
|
|
168
|
+
MongoStore<Item>["partialUpdateByKeyWithInfo"]
|
|
169
|
+
>
|
|
170
|
+
) => {
|
|
171
|
+
await injectOnce();
|
|
172
|
+
return mongoStore.partialUpdateByKeyWithInfo(...args);
|
|
173
|
+
},
|
|
174
|
+
deleteByKeyWithInfo: async (
|
|
175
|
+
...args: Parameters<MongoStore<Item>["deleteByKeyWithInfo"]>
|
|
176
|
+
) => {
|
|
177
|
+
await injectOnce();
|
|
178
|
+
return mongoStore.deleteByKeyWithInfo(...args);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
const injectingSubscribeStore =
|
|
183
|
+
createMongoSubscribeStore(injectingStore);
|
|
184
|
+
const emitted: Actions<Item>[] = [];
|
|
185
|
+
injectingSubscribeStore.subscribe((action) => emitted.push(action));
|
|
186
|
+
return { injectingSubscribeStore, emitted };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const injectConcurrentWrites = async (): Promise<void> => {
|
|
190
|
+
await mongoStore.partialUpdateByKey("2", { $set: { flag: false } });
|
|
191
|
+
await mongoStore.insertOne({ _id: "4", label: "late", flag: true });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
beforeEach(async () => {
|
|
195
|
+
await mongoStore.insertOne({ _id: "2", label: "b", flag: true });
|
|
196
|
+
await mongoStore.insertOne({ _id: "3", label: "c", flag: false });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("deleteMany emits exactly the documents it deleted", async () => {
|
|
200
|
+
const stored1 = await loadStored();
|
|
201
|
+
const { injectingSubscribeStore, emitted } = subscribeInjecting(
|
|
202
|
+
injectConcurrentWrites,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await injectingSubscribeStore.deleteMany({ flag: true });
|
|
206
|
+
|
|
207
|
+
const remaining = await mongoStore.findAll({}, { _id: 1 });
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
remaining.map(({ _id }) => _id),
|
|
210
|
+
["2", "3", "4"],
|
|
211
|
+
);
|
|
212
|
+
assert.deepEqual(emitted, [{ type: "deleted", prev: [stored1] }]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("partialUpdateMany emits exactly the documents it updated, with their true prev", async () => {
|
|
216
|
+
const stored1 = await loadStored();
|
|
217
|
+
const { injectingSubscribeStore, emitted } = subscribeInjecting(
|
|
218
|
+
injectConcurrentWrites,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
await injectingSubscribeStore.partialUpdateMany(
|
|
222
|
+
{ flag: true },
|
|
223
|
+
{ $set: { label: "bulk" } },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const documents = await mongoStore.findAll({}, { _id: 1 });
|
|
227
|
+
assert.deepEqual(
|
|
228
|
+
documents.map(({ _id, label }) => [_id, label]),
|
|
229
|
+
[
|
|
230
|
+
["1", "bulk"],
|
|
231
|
+
["2", "b"],
|
|
232
|
+
["3", "c"],
|
|
233
|
+
["4", "late"],
|
|
234
|
+
],
|
|
235
|
+
);
|
|
236
|
+
assert.deepEqual(emitted, [
|
|
237
|
+
{ type: "updated", changes: [[stored1, documents[0]]] },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("write metadata", () => {
|
|
243
|
+
const oldCreated = new Date("2019-01-01");
|
|
244
|
+
const oldUpdated = new Date("2020-01-01");
|
|
245
|
+
|
|
246
|
+
const resetOld = async (): Promise<Item> => {
|
|
247
|
+
await mongoStore.deleteByKey("old");
|
|
248
|
+
return mongoStore.insertOne({
|
|
249
|
+
_id: "old",
|
|
250
|
+
label: "old",
|
|
251
|
+
flag: true,
|
|
252
|
+
created: oldCreated,
|
|
253
|
+
updated: oldUpdated,
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const loadOld = async (): Promise<Item> =>
|
|
258
|
+
(await mongoStore.findByKey("old"))!;
|
|
259
|
+
|
|
260
|
+
const writes: [string, (old: Item) => Promise<unknown>][] = [
|
|
261
|
+
[
|
|
262
|
+
"MongoStore.partialUpdateByKey",
|
|
263
|
+
() => mongoStore.partialUpdateByKey("old", { $set: { label: "x" } }),
|
|
264
|
+
],
|
|
265
|
+
[
|
|
266
|
+
"SubscribeStore.partialUpdateOne",
|
|
267
|
+
(old) =>
|
|
268
|
+
subscribeStore.partialUpdateOne(old, { $set: { label: "x" } }),
|
|
269
|
+
],
|
|
270
|
+
[
|
|
271
|
+
"MongoStore.partialUpdateMany",
|
|
272
|
+
() =>
|
|
273
|
+
mongoStore.partialUpdateMany({ _id: "old" }, { $inc: { __v: 1 } }),
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
"SubscribeStore.partialUpdateMany",
|
|
277
|
+
() =>
|
|
278
|
+
subscribeStore.partialUpdateMany(
|
|
279
|
+
{ _id: "old" },
|
|
280
|
+
{ $set: { label: "x" } },
|
|
281
|
+
),
|
|
282
|
+
],
|
|
283
|
+
[
|
|
284
|
+
"MongoStore.replaceOne with a loaded document",
|
|
285
|
+
(old) => mongoStore.replaceOne({ ...old, label: "x" }),
|
|
286
|
+
],
|
|
287
|
+
[
|
|
288
|
+
"SubscribeStore.replaceOne with another created",
|
|
289
|
+
(old) =>
|
|
290
|
+
subscribeStore.replaceOne({
|
|
291
|
+
...old,
|
|
292
|
+
created: new Date("2025-01-01"),
|
|
293
|
+
}),
|
|
294
|
+
],
|
|
295
|
+
[
|
|
296
|
+
"upsertOne without updated",
|
|
297
|
+
() =>
|
|
298
|
+
subscribeStore.upsertOne({ _id: "old", label: "x", flag: false }),
|
|
299
|
+
],
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const [name, write] of writes) {
|
|
303
|
+
it(`${name} moves updated forward and keeps created`, async () => {
|
|
304
|
+
const old = await resetOld();
|
|
305
|
+
const writeStart = Date.now();
|
|
306
|
+
|
|
307
|
+
await write(old);
|
|
308
|
+
|
|
309
|
+
const stored = await loadOld();
|
|
310
|
+
assert.ok(stored.updated.getTime() >= writeStart);
|
|
311
|
+
assert.deepEqual(stored.created, oldCreated);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
it("keeps updated with touchUpdated: false", async () => {
|
|
316
|
+
await resetOld();
|
|
317
|
+
|
|
318
|
+
await mongoStore.partialUpdateMany(
|
|
319
|
+
{ _id: "old" },
|
|
320
|
+
{ $set: { label: "backfill" } },
|
|
321
|
+
{ touchUpdated: false },
|
|
322
|
+
);
|
|
323
|
+
await subscribeStore.partialUpdateByKey(
|
|
324
|
+
"old",
|
|
325
|
+
{ $set: { flag: false } },
|
|
326
|
+
undefined,
|
|
327
|
+
{ touchUpdated: false },
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const stored = await loadOld();
|
|
331
|
+
assert.deepEqual(stored.updated, oldUpdated);
|
|
332
|
+
assert.equal(stored.label, "backfill");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("stores an explicit updated set by the caller", async () => {
|
|
336
|
+
await resetOld();
|
|
337
|
+
const callerTime = new Date("2024-06-01");
|
|
338
|
+
|
|
339
|
+
const afterSet = await mongoStore.partialUpdateByKey("old", {
|
|
340
|
+
$set: { updated: callerTime },
|
|
341
|
+
});
|
|
342
|
+
assert.deepEqual(afterSet.updated, callerTime);
|
|
343
|
+
|
|
344
|
+
const afterMax = await mongoStore.partialUpdateByKey("old", {
|
|
345
|
+
$max: { updated: new Date("2024-01-01") },
|
|
346
|
+
});
|
|
347
|
+
assert.deepEqual(afterMax.updated, callerTime);
|
|
348
|
+
|
|
349
|
+
const writeStart = Date.now();
|
|
350
|
+
const afterCurrentDate = await mongoStore.partialUpdateByKey("old", {
|
|
351
|
+
$currentDate: { updated: true },
|
|
352
|
+
});
|
|
353
|
+
// $currentDate uses the mongod clock; allow for a small skew with this process
|
|
354
|
+
assert.ok(afterCurrentDate.updated.getTime() >= writeStart - 1000);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("rejects protected field updates before mongo sees them", async () => {
|
|
358
|
+
await resetOld();
|
|
359
|
+
const forbidden: Record<string, unknown>[] = [
|
|
360
|
+
{ $set: { created: new Date() } },
|
|
361
|
+
{ $unset: { updated: true } },
|
|
362
|
+
{ $inc: { updated: 1 } },
|
|
363
|
+
{ $min: { updated: new Date() } },
|
|
364
|
+
{ $setOnInsert: { updated: new Date() } },
|
|
365
|
+
{ $rename: { updated: "lastUpdate" } },
|
|
366
|
+
{ $rename: { label: "created" } },
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
for (const update of forbidden) {
|
|
370
|
+
await assert.rejects(
|
|
371
|
+
mongoStore.partialUpdateByKey("old", update),
|
|
372
|
+
ProtectedFieldUpdateError,
|
|
373
|
+
JSON.stringify(update),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const stored = await loadOld();
|
|
378
|
+
assert.deepEqual(stored.updated, oldUpdated);
|
|
379
|
+
assert.deepEqual(stored.created, oldCreated);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("stores replacement values starting with $ as data", async () => {
|
|
383
|
+
const old = await resetOld();
|
|
384
|
+
|
|
385
|
+
const replaced = await mongoStore.replaceOne({
|
|
386
|
+
...old,
|
|
387
|
+
label: "$label",
|
|
388
|
+
});
|
|
389
|
+
assert.equal(replaced.label, "$label");
|
|
390
|
+
|
|
391
|
+
await subscribeStore.replaceOne({
|
|
392
|
+
...old,
|
|
393
|
+
flag: false,
|
|
394
|
+
label: "$flag",
|
|
395
|
+
});
|
|
396
|
+
const stored = await loadOld();
|
|
397
|
+
assert.equal(stored.label, "$flag");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("returns the upserted next as stored, undefined values included", async () => {
|
|
401
|
+
await resetOld();
|
|
402
|
+
|
|
403
|
+
const result = await mongoStore.upsertOneWithInfo({
|
|
404
|
+
_id: "old",
|
|
405
|
+
label: "x",
|
|
406
|
+
flag: false,
|
|
407
|
+
note: undefined,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
assert.deepEqual(result.object, await loadOld());
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
);
|
|
@@ -256,9 +256,9 @@ describe("diffIndexes", () => {
|
|
|
256
256
|
key: { _fts: "text", _ftsx: 1 },
|
|
257
257
|
name: "label_text",
|
|
258
258
|
weights: { label: 1 },
|
|
259
|
-
//
|
|
259
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
260
260
|
default_language: "english",
|
|
261
|
-
//
|
|
261
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
262
262
|
language_override: "language",
|
|
263
263
|
textIndexVersion: 3,
|
|
264
264
|
},
|
|
@@ -101,9 +101,9 @@ describe("normalizeExistingIndex", () => {
|
|
|
101
101
|
key: { completed: 1, _fts: "text", _ftsx: 1 },
|
|
102
102
|
name: "completed_1_label_text",
|
|
103
103
|
weights: { label: 1 },
|
|
104
|
-
//
|
|
104
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
105
105
|
default_language: "english",
|
|
106
|
-
//
|
|
106
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
107
107
|
language_override: "language",
|
|
108
108
|
textIndexVersion: 3,
|
|
109
109
|
});
|
|
@@ -57,11 +57,11 @@ const buildDescription = (
|
|
|
57
57
|
if (index.collation) description.collation = index.collation;
|
|
58
58
|
if (index.weights) description.weights = index.weights;
|
|
59
59
|
if (index.default_language) {
|
|
60
|
-
//
|
|
60
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
61
61
|
description.default_language = index.default_language;
|
|
62
62
|
}
|
|
63
63
|
if (index.language_override) {
|
|
64
|
-
//
|
|
64
|
+
// oxlint-disable-next-line eslint-js/camelcase -- mongo option name
|
|
65
65
|
description.language_override = index.language_override;
|
|
66
66
|
}
|
|
67
67
|
if (index.wildcardProjection) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Document } from "mongodb";
|
|
2
|
+
import type { MongoBaseModel } from "../MongoBaseModel.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Replaces the stored document with `replacement` while keeping the stored `created`
|
|
6
|
+
* (falling back to the replacement's when the stored document has none).
|
|
7
|
+
* `$literal` keeps string values starting with `$` from being read as field paths.
|
|
8
|
+
* Update pipelines require MongoDB >= 4.2.
|
|
9
|
+
*/
|
|
10
|
+
export const createReplacePipeline = (
|
|
11
|
+
replacement: MongoBaseModel<any>,
|
|
12
|
+
): Document[] => [
|
|
13
|
+
{
|
|
14
|
+
$replaceWith: {
|
|
15
|
+
$mergeObjects: [
|
|
16
|
+
{ $literal: replacement },
|
|
17
|
+
{
|
|
18
|
+
created: { $ifNull: ["$created", { $literal: replacement.created }] },
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|