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.
Files changed (31) hide show
  1. package/README.md +27 -2
  2. package/dist/definitions/MongoCursor.d.ts.map +1 -1
  3. package/dist/definitions/MongoQuerySingleItem.d.ts +1 -0
  4. package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
  5. package/dist/definitions/MongoQuerySingleItem.test.d.ts +2 -0
  6. package/dist/definitions/MongoQuerySingleItem.test.d.ts.map +1 -0
  7. package/dist/definitions/MongoStore.d.ts +18 -4
  8. package/dist/definitions/MongoStore.d.ts.map +1 -1
  9. package/dist/definitions/SubscribeStore.integration.test.d.ts +2 -0
  10. package/dist/definitions/SubscribeStore.integration.test.d.ts.map +1 -0
  11. package/dist/definitions/writes/createReplacePipeline.d.ts +10 -0
  12. package/dist/definitions/writes/createReplacePipeline.d.ts.map +1 -0
  13. package/dist/definitions/writes/stampPartialUpdate.d.ts +9 -0
  14. package/dist/definitions/writes/stampPartialUpdate.d.ts.map +1 -0
  15. package/dist/definitions/writes/stampPartialUpdate.test.d.ts +2 -0
  16. package/dist/definitions/writes/stampPartialUpdate.test.d.ts.map +1 -0
  17. package/dist/index-node.mjs +205 -81
  18. package/dist/index-node.mjs.map +1 -1
  19. package/package.json +5 -5
  20. package/src/MongoCursor.ts +2 -4
  21. package/src/MongoQuerySingleItem.test.ts +162 -0
  22. package/src/MongoQuerySingleItem.ts +56 -50
  23. package/src/MongoStore.test.ts +250 -0
  24. package/src/MongoStore.ts +122 -26
  25. package/src/SubscribeStore.integration.test.ts +414 -0
  26. package/src/indexes/diffIndexes.test.ts +2 -2
  27. package/src/indexes/normalizeIndex.test.ts +2 -2
  28. package/src/indexes/normalizeIndex.ts +2 -2
  29. package/src/writes/createReplacePipeline.ts +23 -0
  30. package/src/writes/stampPartialUpdate.test.ts +93 -0
  31. 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
- if (!object.updated) object.updated = new Date();
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.replaceOne({ _id: object._id } as Filter<Model>, object);
205
- return object;
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, ...$set };
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 commandResult = await collection.updateOne(
340
+ const next = await collection.findOneAndUpdate(
284
341
  { _id: key, ...criteria } as Filter<Model>,
285
- partialUpdate as UpdateFilter<Model>,
342
+ update as UpdateFilter<Model>,
343
+ { returnDocument: "after" },
286
344
  );
287
- if (!commandResult.acknowledged) {
288
- console.error(commandResult);
289
- throw new Error("Update failed");
290
- }
291
- const object = await this.findByKey(key);
292
- return object!;
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(object._id, partialUpdate);
377
+ return this.partialUpdateByKey(
378
+ object._id,
379
+ partialUpdate,
380
+ undefined,
381
+ options,
382
+ );
300
383
  }
301
384
 
302
- partialUpdateMany(
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
- return this.collection
307
- .then((collection) =>
308
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
309
- collection.updateMany(criteria as Filter<Model>, partialUpdate as any),
310
- )
311
- .then((res) => undefined); // TODO return updated object
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
- return this.collection
316
- .then((collection) =>
317
- collection.deleteOne({ _id: key, ...criteria } as Filter<Model>),
318
- )
319
- .then(() => undefined);
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
- // eslint-disable-next-line camelcase -- mongo option name
259
+ // oxlint-disable-next-line eslint-js/camelcase -- mongo option name
260
260
  default_language: "english",
261
- // eslint-disable-next-line camelcase -- mongo option name
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
- // eslint-disable-next-line camelcase -- mongo option name
104
+ // oxlint-disable-next-line eslint-js/camelcase -- mongo option name
105
105
  default_language: "english",
106
- // eslint-disable-next-line camelcase -- mongo option name
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
- // eslint-disable-next-line camelcase -- mongo option name
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
- // eslint-disable-next-line camelcase -- mongo option name
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
+ ];