liwi-mongo 13.3.3 → 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 (40) hide show
  1. package/README.md +27 -2
  2. package/dist/definitions/MongoConnection.d.ts +7 -1
  3. package/dist/definitions/MongoConnection.d.ts.map +1 -1
  4. package/dist/definitions/MongoConnection.test.d.ts +2 -0
  5. package/dist/definitions/MongoConnection.test.d.ts.map +1 -0
  6. package/dist/definitions/MongoCursor.d.ts.map +1 -1
  7. package/dist/definitions/MongoQuerySingleItem.d.ts +1 -0
  8. package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
  9. package/dist/definitions/MongoQuerySingleItem.test.d.ts +2 -0
  10. package/dist/definitions/MongoQuerySingleItem.test.d.ts.map +1 -0
  11. package/dist/definitions/MongoStore.d.ts +18 -4
  12. package/dist/definitions/MongoStore.d.ts.map +1 -1
  13. package/dist/definitions/SubscribeStore.integration.test.d.ts +2 -0
  14. package/dist/definitions/SubscribeStore.integration.test.d.ts.map +1 -0
  15. package/dist/definitions/writes/createReplacePipeline.d.ts +10 -0
  16. package/dist/definitions/writes/createReplacePipeline.d.ts.map +1 -0
  17. package/dist/definitions/writes/stampPartialUpdate.d.ts +9 -0
  18. package/dist/definitions/writes/stampPartialUpdate.d.ts.map +1 -0
  19. package/dist/definitions/writes/stampPartialUpdate.test.d.ts +2 -0
  20. package/dist/definitions/writes/stampPartialUpdate.test.d.ts.map +1 -0
  21. package/dist/index-node.mjs +225 -93
  22. package/dist/index-node.mjs.map +1 -1
  23. package/package.json +9 -8
  24. package/src/MongoConnection.test.ts +75 -0
  25. package/src/MongoConnection.ts +38 -25
  26. package/src/MongoCursor.ts +2 -4
  27. package/src/MongoQueryCollection.test.ts +9 -23
  28. package/src/MongoQueryCollection.ts +2 -2
  29. package/src/MongoQuerySingleItem.test.ts +162 -0
  30. package/src/MongoQuerySingleItem.ts +58 -52
  31. package/src/MongoStore.test.ts +259 -18
  32. package/src/MongoStore.ts +122 -26
  33. package/src/MongoStoreIndexes.test.ts +10 -24
  34. package/src/SubscribeStore.integration.test.ts +414 -0
  35. package/src/indexes/diffIndexes.test.ts +2 -2
  36. package/src/indexes/normalizeIndex.test.ts +2 -2
  37. package/src/indexes/normalizeIndex.ts +2 -2
  38. package/src/writes/createReplacePipeline.ts +23 -0
  39. package/src/writes/stampPartialUpdate.test.ts +93 -0
  40. package/src/writes/stampPartialUpdate.ts +84 -0
@@ -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
+ ];
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ProtectedFieldUpdateError } from "liwi-store";
4
+ import type { Update } from "liwi-store";
5
+ import type { MongoBaseModel } from "../MongoBaseModel.ts";
6
+ import { stampPartialUpdate } from "./stampPartialUpdate.ts";
7
+
8
+ interface TestModel extends MongoBaseModel {
9
+ label: string;
10
+ count: number;
11
+ }
12
+
13
+ const now = new Date("2026-10-11T10:00:00Z");
14
+ const callerTime = new Date("2026-01-01");
15
+
16
+ const stamp = (update: Record<string, unknown>): Update<TestModel> =>
17
+ stampPartialUpdate(update as Update<TestModel>, now);
18
+
19
+ describe("stampPartialUpdate", () => {
20
+ it("adds updated to $set", () => {
21
+ assert.deepEqual(stamp({ $set: { label: "a" }, $inc: { count: 1 } }), {
22
+ $set: { label: "a", updated: now },
23
+ $inc: { count: 1 },
24
+ });
25
+ });
26
+
27
+ it("adds a $set when the update has none", () => {
28
+ assert.deepEqual(stamp({ $inc: { count: 1 } }), {
29
+ $inc: { count: 1 },
30
+ $set: { updated: now },
31
+ });
32
+ });
33
+
34
+ it("does not stamp without a write time", () => {
35
+ const update: Update<TestModel> = { $set: { label: "a" } };
36
+
37
+ assert.equal(stampPartialUpdate(update, undefined), update);
38
+ });
39
+
40
+ it("does not mutate the caller's update", () => {
41
+ const update = { $set: { label: "a" } };
42
+
43
+ stamp(update);
44
+
45
+ assert.deepEqual(update, { $set: { label: "a" } });
46
+ });
47
+
48
+ for (const update of [
49
+ { $set: { updated: callerTime } },
50
+ { $currentDate: { updated: true } },
51
+ { $max: { updated: callerTime } },
52
+ ]) {
53
+ it(`lets the caller set updated with ${Object.keys(update)[0]!}`, () => {
54
+ assert.equal(stamp(update), update);
55
+ });
56
+ }
57
+
58
+ for (const update of [
59
+ { $unset: { updated: true } },
60
+ { $inc: { updated: 1 } },
61
+ { $mul: { updated: 2 } },
62
+ { $min: { updated: callerTime } },
63
+ { $setOnInsert: { updated: callerTime } },
64
+ { $rename: { updated: "lastUpdate" } },
65
+ { $rename: { lastUpdate: "updated" } },
66
+ { $push: { updated: callerTime } },
67
+ { $set: { "updated.day": 1 } },
68
+ ]) {
69
+ it(`rejects ${JSON.stringify(update)}`, () => {
70
+ assert.throws(() => stamp(update), ProtectedFieldUpdateError);
71
+ });
72
+ }
73
+
74
+ for (const update of [
75
+ { $set: { created: callerTime } },
76
+ { $currentDate: { created: true } },
77
+ { $max: { created: callerTime } },
78
+ { $unset: { created: true } },
79
+ { $rename: { created: "creation" } },
80
+ { $rename: { creation: "created" } },
81
+ { $set: { "created.day": 1 } },
82
+ ]) {
83
+ it(`rejects ${JSON.stringify(update)}`, () => {
84
+ assert.throws(() => stamp(update), ProtectedFieldUpdateError);
85
+ });
86
+ }
87
+
88
+ it("ignores fields that only start like a protected field", () => {
89
+ assert.deepEqual(stamp({ $set: { updatedBy: "bot", createdBy: "bot" } }), {
90
+ $set: { updatedBy: "bot", createdBy: "bot", updated: now },
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,84 @@
1
+ import { ProtectedFieldUpdateError } from "liwi-store";
2
+ import type { BaseModel, Update } from "liwi-store";
3
+
4
+ type ProtectedField = "created" | "updated";
5
+
6
+ const protectedFields: readonly ProtectedField[] = ["created", "updated"];
7
+
8
+ const updatedOverrideOperators: ReadonlySet<string> = new Set([
9
+ "$set",
10
+ "$currentDate",
11
+ "$max",
12
+ ]);
13
+
14
+ const findProtectedField = (path: string): ProtectedField | undefined =>
15
+ protectedFields.find(
16
+ (field) => path === field || path.startsWith(`${field}.`),
17
+ );
18
+
19
+ const listTouchedPaths = (
20
+ operator: string,
21
+ fields: Record<string, unknown>,
22
+ ): string[] => {
23
+ const keys = Object.keys(fields);
24
+ if (operator !== "$rename") return keys;
25
+ const renameTargets = Object.values(fields).filter(
26
+ (target): target is string => typeof target === "string",
27
+ );
28
+ return [...keys, ...renameTargets];
29
+ };
30
+
31
+ const describeRejection = (
32
+ field: ProtectedField,
33
+ operator: string,
34
+ path: string,
35
+ ): string =>
36
+ field === "created"
37
+ ? `Cannot use ${operator} on "${path}": created is set on insert and never updated`
38
+ : `Cannot use ${operator} on "${path}": updated can only be set with $set, $currentDate or $max`;
39
+
40
+ const validateProtectedFields = <Model extends BaseModel>(
41
+ update: Update<Model>,
42
+ ): { isUpdatedSetByCaller: boolean } => {
43
+ let isUpdatedSetByCaller = false;
44
+ const operations = Object.entries(
45
+ update as Record<string, Record<string, unknown> | undefined>,
46
+ );
47
+
48
+ for (const [operator, fields] of operations) {
49
+ if (!fields) continue;
50
+ for (const path of listTouchedPaths(operator, fields)) {
51
+ const field = findProtectedField(path);
52
+ if (!field) continue;
53
+ if (
54
+ field === "updated" &&
55
+ path === "updated" &&
56
+ updatedOverrideOperators.has(operator)
57
+ ) {
58
+ isUpdatedSetByCaller = true;
59
+ continue;
60
+ }
61
+ throw new ProtectedFieldUpdateError(
62
+ describeRejection(field, operator, path),
63
+ );
64
+ }
65
+ }
66
+
67
+ return { isUpdatedSetByCaller };
68
+ };
69
+
70
+ /**
71
+ * Throws `ProtectedFieldUpdateError` when the update touches `created`, or touches
72
+ * `updated` with an operator other than `$set`, `$currentDate` or `$max`.
73
+ * Otherwise returns the update with `updated` set to `now`, unless the caller sets
74
+ * `updated` itself or `now` is `undefined`. The caller's update is never mutated.
75
+ */
76
+ export const stampPartialUpdate = <Model extends BaseModel>(
77
+ update: Update<Model>,
78
+ now: Date | undefined,
79
+ ): Update<Model> => {
80
+ const { isUpdatedSetByCaller } = validateProtectedFields(update);
81
+ if (isUpdatedSetByCaller || !now) return update;
82
+ const $set: Record<string, unknown> = { ...update.$set, updated: now };
83
+ return { ...update, $set: $set as Update<Model>["$set"] };
84
+ };