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
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { setImmediate } from "node:timers/promises";
4
+ import type { Changes } from "liwi-store";
5
+ import { SubscribeStore } from "liwi-subscribe-store";
6
+ import type { MongoBaseModel } from "./MongoBaseModel.ts";
7
+ import MongoQuerySingleItem from "./MongoQuerySingleItem.ts";
8
+ import type MongoStore from "./MongoStore.ts";
9
+
10
+ interface TestModel extends MongoBaseModel {
11
+ status: string;
12
+ }
13
+
14
+ const created = new Date("2020-01-01");
15
+ const updated = new Date("2021-01-01");
16
+
17
+ interface SubscribeSingleItemOptions {
18
+ cursor?: MongoStore<TestModel>["cursor"];
19
+ sort?: Record<string, -1 | 1>;
20
+ }
21
+
22
+ const subscribeSingleItem = ({
23
+ cursor = () => Promise.reject(new Error("unexpected fetch")),
24
+ sort,
25
+ }: SubscribeSingleItemOptions = {}): {
26
+ subscribeStore: SubscribeStore<any, any, TestModel, any, any, any>;
27
+ received: Changes<string, TestModel | null>[];
28
+ errors: Error[];
29
+ } => {
30
+ const store = { keyPath: "_id", cursor } as unknown as MongoStore<TestModel>;
31
+ const subscribeStore = new SubscribeStore<any, any, TestModel, any, any, any>(
32
+ store,
33
+ );
34
+ const query = new MongoQuerySingleItem<TestModel, never, TestModel | null>(
35
+ store,
36
+ { criteria: { status: "open" }, sort },
37
+ );
38
+ query.setSubscribeStore(subscribeStore);
39
+
40
+ const received: Changes<string, TestModel | null>[] = [];
41
+ const errors: Error[] = [];
42
+ query.subscribe((error, changes) => {
43
+ if (error) errors.push(error);
44
+ else received.push(changes);
45
+ });
46
+ return { subscribeStore, received, errors };
47
+ };
48
+
49
+ const createCursor = (
50
+ documents: TestModel[],
51
+ ): MongoStore<TestModel>["cursor"] =>
52
+ (() => {
53
+ const cursor = {
54
+ limit: () => Promise.resolve(cursor),
55
+ toArray: () => Promise.resolve(documents),
56
+ };
57
+ return Promise.resolve(cursor);
58
+ }) as unknown as MongoStore<TestModel>["cursor"];
59
+
60
+ const draft: TestModel = { _id: "1", status: "draft", created, updated };
61
+ const open: TestModel = { _id: "1", status: "open", created, updated };
62
+ const closed: TestModel = { _id: "1", status: "closed", created, updated };
63
+ const draft2: TestModel = { ...draft, _id: "2" };
64
+ const open2: TestModel = { ...open, _id: "2" };
65
+
66
+ describe("MongoQuerySingleItem subscription", () => {
67
+ it("pushes a document that enters the criteria through an update", async () => {
68
+ const { subscribeStore, received } = subscribeSingleItem();
69
+
70
+ subscribeStore.callSubscribed({
71
+ type: "updated",
72
+ changes: [[draft, open]],
73
+ });
74
+ await setImmediate();
75
+
76
+ assert.deepEqual(received, [[{ type: "updated", result: open }]]);
77
+ });
78
+
79
+ it("pushes null when the document leaves the criteria", async () => {
80
+ const { subscribeStore, received } = subscribeSingleItem();
81
+
82
+ subscribeStore.callSubscribed({
83
+ type: "updated",
84
+ changes: [[open, closed]],
85
+ });
86
+ await setImmediate();
87
+
88
+ assert.deepEqual(received, [[{ type: "updated", result: null }]]);
89
+ });
90
+
91
+ it("ignores an update outside the criteria before and after", async () => {
92
+ const { subscribeStore, received, errors } = subscribeSingleItem();
93
+
94
+ subscribeStore.callSubscribed({
95
+ type: "updated",
96
+ changes: [[draft, closed]],
97
+ });
98
+ await setImmediate();
99
+
100
+ assert.deepEqual(received, []);
101
+ assert.deepEqual(errors, []);
102
+ });
103
+
104
+ it("reports several matches without sort through the callback", async () => {
105
+ const { subscribeStore, received, errors } = subscribeSingleItem();
106
+
107
+ subscribeStore.callSubscribed({
108
+ type: "updated",
109
+ changes: [
110
+ [draft, open],
111
+ [draft2, open2],
112
+ ],
113
+ });
114
+ await setImmediate();
115
+
116
+ assert.deepEqual(received, []);
117
+ assert.deepEqual(
118
+ errors.map((error) => error.message),
119
+ ["should not match more than 1, use sort if you can have multiple match"],
120
+ );
121
+ });
122
+
123
+ it("refetches with a sort when several documents enter the criteria", async () => {
124
+ const { subscribeStore, received, errors } = subscribeSingleItem({
125
+ cursor: createCursor([open2]),
126
+ sort: { _id: -1 },
127
+ });
128
+
129
+ subscribeStore.callSubscribed({
130
+ type: "updated",
131
+ changes: [
132
+ [draft, open],
133
+ [draft2, open2],
134
+ ],
135
+ });
136
+ await setImmediate();
137
+
138
+ assert.deepEqual(errors, []);
139
+ assert.deepEqual(received, [[{ type: "updated", result: open2 }]]);
140
+ });
141
+
142
+ it("reports a failed refetch through the callback", async () => {
143
+ const { subscribeStore, received, errors } = subscribeSingleItem({
144
+ sort: { _id: -1 },
145
+ });
146
+
147
+ subscribeStore.callSubscribed({
148
+ type: "updated",
149
+ changes: [
150
+ [draft, open],
151
+ [draft2, open2],
152
+ ],
153
+ });
154
+ await setImmediate();
155
+
156
+ assert.deepEqual(received, []);
157
+ assert.deepEqual(
158
+ errors.map((error) => error.message),
159
+ ["unexpected fetch"],
160
+ );
161
+ });
162
+ });
@@ -90,6 +90,56 @@ export default class MongoQuerySingleItem<
90
90
  });
91
91
  }
92
92
 
93
+ private async computeChanges(
94
+ action: Actions<Model>,
95
+ testCriteria: TestCriteria,
96
+ ): Promise<Changes<KeyValue, Result>> {
97
+ switch (action.type) {
98
+ case "inserted": {
99
+ const filtered = action.next.filter(testCriteria);
100
+ if (filtered.length === 0) return [];
101
+ return [{ type: "updated", result: this.transformer(filtered[0]!) }];
102
+ }
103
+ case "deleted": {
104
+ const filtered = action.prev.filter(testCriteria);
105
+ if (filtered.length === 0) return [];
106
+ return [
107
+ {
108
+ type: "deleted",
109
+ keys: filtered.map((object) => object[this.store.keyPath]),
110
+ },
111
+ ];
112
+ }
113
+ case "updated": {
114
+ const filtered = action.changes.filter(
115
+ ([prev, next]) => testCriteria(prev) || testCriteria(next),
116
+ );
117
+ if (filtered.length === 0) return [];
118
+
119
+ if (this.options.sort) {
120
+ const { result } = await this.fetch((res) => res);
121
+ return [{ type: "updated", result }];
122
+ }
123
+
124
+ if (filtered.length > 1) {
125
+ throw new Error(
126
+ "should not match more than 1, use sort if you can have multiple match",
127
+ );
128
+ }
129
+
130
+ const [, next] = filtered[0]!;
131
+ return [
132
+ {
133
+ type: "updated",
134
+ result: testCriteria(next) ? this.transformer(next) : null!,
135
+ },
136
+ ];
137
+ }
138
+ default:
139
+ throw new Error("Unsupported type");
140
+ }
141
+ }
142
+
93
143
  _subscribe(
94
144
  callback: SubscribeCallback<KeyValue, Result>,
95
145
  _includeInitial: boolean,
@@ -111,56 +161,12 @@ export default class MongoQuerySingleItem<
111
161
  : Promise.resolve();
112
162
 
113
163
  const unsubscribe = store.subscribe(async (action: Actions<Model>) => {
114
- const changes: Changes<KeyValue, Result> = [];
115
- switch (action.type) {
116
- case "inserted": {
117
- const filtered = action.next.filter(testCriteria);
118
- if (filtered.length > 0) {
119
- changes.push({
120
- type: "updated",
121
- result: this.transformer(filtered[0]!),
122
- });
123
- }
124
- break;
125
- }
126
- case "deleted": {
127
- const filtered = action.prev.filter(testCriteria);
128
- if (filtered.length > 0) {
129
- changes.push({
130
- type: "deleted",
131
- keys: filtered.map((object) => object[this.store.keyPath]),
132
- });
133
- }
134
- break;
135
- }
136
- case "updated": {
137
- const filtered = action.changes.filter(([prev, next]) =>
138
- testCriteria(prev),
139
- );
140
- if (filtered.length > 0) {
141
- if (this.options.sort) {
142
- const { result } = await this.fetch((res) => res);
143
- changes.push({
144
- type: "updated",
145
- result,
146
- });
147
- } else if (filtered.length !== 1) {
148
- throw new Error(
149
- "should not match more than 1, use sort if you can have multiple match",
150
- );
151
- } else {
152
- const [, next] = filtered[0]!;
153
- changes.push({
154
- type: "updated",
155
- result: testCriteria(next) ? this.transformer(next) : null!,
156
- });
157
- }
158
- } else if (filtered.length === 0) {
159
- }
160
- break;
161
- }
162
- default:
163
- throw new Error("Unsupported type");
164
+ let changes: Changes<KeyValue, Result>;
165
+ try {
166
+ changes = await this.computeChanges(action, testCriteria);
167
+ } catch (error: unknown) {
168
+ callback(error as Error, []);
169
+ return;
164
170
  }
165
171
 
166
172
  if (changes.length === 0) return;
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { beforeEach, describe, it, mock } from "node:test";
3
+ import { NotFoundError, ProtectedFieldUpdateError } from "liwi-store";
3
4
  import mongodb from "mongodb";
4
5
  import type { MongoBaseModel } from "./MongoBaseModel.ts";
5
6
  import MongoConnection from "./MongoConnection.ts";
6
7
  import MongoStore from "./MongoStore.ts";
8
+ import { createReplacePipeline } from "./writes/createReplacePipeline.ts";
7
9
 
8
10
  interface TestModel extends MongoBaseModel {
9
11
  label: string;
@@ -11,13 +13,26 @@ interface TestModel extends MongoBaseModel {
11
13
  }
12
14
 
13
15
  let findOneAndUpdateCalls: unknown[][];
16
+ let findOneAndDeleteCalls: unknown[][];
17
+ let updateManyCalls: unknown[][];
14
18
  let existingDocument: TestModel | null;
19
+ let documentAfterWrite: TestModel | null;
15
20
 
16
21
  const collection = {
17
22
  findOneAndUpdate: (...args: unknown[]) => {
18
23
  findOneAndUpdateCalls.push(args);
19
24
  return Promise.resolve(existingDocument);
20
25
  },
26
+ findOneAndDelete: (...args: unknown[]) => {
27
+ findOneAndDeleteCalls.push(args);
28
+ return Promise.resolve(existingDocument);
29
+ },
30
+ findOne: () => Promise.resolve(documentAfterWrite),
31
+ updateMany: (...args: unknown[]) => {
32
+ updateManyCalls.push(args);
33
+ return Promise.resolve({ matchedCount: 1 });
34
+ },
35
+ deleteOne: () => Promise.resolve({ deletedCount: existingDocument ? 1 : 0 }),
21
36
  };
22
37
 
23
38
  mock.method(mongodb.MongoClient, "connect", () =>
@@ -97,4 +112,239 @@ describe("MongoStore upsertOneWithInfo", () => {
97
112
  prev: existingDocument,
98
113
  });
99
114
  });
115
+
116
+ it("computes next with undefined values as the driver stores them", async () => {
117
+ existingDocument = {
118
+ _id: "1",
119
+ label: "old",
120
+ status: "x",
121
+ created,
122
+ updated,
123
+ };
124
+
125
+ const result = await createStore().upsertOneWithInfo({
126
+ _id: "1",
127
+ label: "a",
128
+ status: undefined,
129
+ updated,
130
+ });
131
+
132
+ assert.equal(result.object.status, null);
133
+ });
134
+
135
+ it("rejects updated in setOnInsert, which would conflict with $set", async () => {
136
+ await assert.rejects(
137
+ createStore().upsertOneWithInfo(
138
+ { _id: "1", label: "a" },
139
+ { updated: new Date() },
140
+ ),
141
+ ProtectedFieldUpdateError,
142
+ );
143
+ assert.deepEqual(findOneAndUpdateCalls, []);
144
+ });
145
+ });
146
+
147
+ describe("MongoStore writes with info", () => {
148
+ const stored: TestModel = { _id: "1", label: "stored", created, updated };
149
+ const next: TestModel = { _id: "1", label: "next", created, updated };
150
+ let writeStart: number;
151
+
152
+ const assertWriteTime = (value: unknown): Date => {
153
+ assert.ok(value instanceof Date);
154
+ assert.ok(value.getTime() >= writeStart && value.getTime() <= Date.now());
155
+ return value;
156
+ };
157
+
158
+ const stampOf = (update: unknown): unknown =>
159
+ (update as { $set: { updated: unknown } }).$set.updated;
160
+
161
+ const replacementStampOf = (pipeline: unknown): unknown =>
162
+ (
163
+ pipeline as [
164
+ {
165
+ $replaceWith: {
166
+ $mergeObjects: [{ $literal: { updated: unknown } }];
167
+ };
168
+ },
169
+ ]
170
+ )[0].$replaceWith.$mergeObjects[0].$literal.updated;
171
+
172
+ beforeEach(() => {
173
+ writeStart = Date.now();
174
+ findOneAndUpdateCalls = [];
175
+ findOneAndDeleteCalls = [];
176
+ updateManyCalls = [];
177
+ existingDocument = stored;
178
+ documentAfterWrite = next;
179
+ });
180
+
181
+ it("partially updates atomically with the criteria and the write time, then reads next", async () => {
182
+ const result = await createStore().partialUpdateByKeyWithInfo(
183
+ "1",
184
+ { $set: { label: "next" } },
185
+ { status: "open" },
186
+ );
187
+
188
+ const writeTime = assertWriteTime(stampOf(findOneAndUpdateCalls[0]?.[1]));
189
+ assert.deepEqual(findOneAndUpdateCalls, [
190
+ [
191
+ { _id: "1", status: "open" },
192
+ { $set: { label: "next", updated: writeTime } },
193
+ { returnDocument: "before" },
194
+ ],
195
+ ]);
196
+ assert.deepEqual(result, { prev: stored, next });
197
+ });
198
+
199
+ it("does not stamp updated with touchUpdated: false", async () => {
200
+ await createStore().partialUpdateByKey(
201
+ "1",
202
+ { $set: { label: "next" } },
203
+ undefined,
204
+ { touchUpdated: false },
205
+ );
206
+
207
+ assert.deepEqual(findOneAndUpdateCalls, [
208
+ [{ _id: "1" }, { $set: { label: "next" } }, { returnDocument: "after" }],
209
+ ]);
210
+ });
211
+
212
+ it("rejects a protected field update before writing", async () => {
213
+ await assert.rejects(
214
+ createStore().partialUpdateByKey("1", { $set: { created: new Date() } }),
215
+ ProtectedFieldUpdateError,
216
+ );
217
+ assert.deepEqual(findOneAndUpdateCalls, []);
218
+ });
219
+
220
+ it("partially updates many documents in one command with one write time", async () => {
221
+ await createStore().partialUpdateMany(
222
+ { status: "open" },
223
+ { $set: { label: "next" } },
224
+ );
225
+
226
+ const writeTime = assertWriteTime(stampOf(updateManyCalls[0]?.[1]));
227
+ assert.deepEqual(updateManyCalls, [
228
+ [{ status: "open" }, { $set: { label: "next", updated: writeTime } }],
229
+ ]);
230
+ });
231
+
232
+ it("resolves next undefined when the document is deleted before the read-back", async () => {
233
+ documentAfterWrite = null;
234
+
235
+ const result = await createStore().partialUpdateByKeyWithInfo("1", {
236
+ $set: { label: "next" },
237
+ });
238
+
239
+ assert.deepEqual(result, { prev: stored, next: undefined });
240
+ });
241
+
242
+ it("resolves undefined when the partial update matches nothing", async () => {
243
+ existingDocument = null;
244
+
245
+ const store = createStore();
246
+
247
+ assert.equal(
248
+ await store.partialUpdateByKeyWithInfo("1", { $set: { label: "x" } }),
249
+ undefined,
250
+ );
251
+ await assert.rejects(
252
+ store.partialUpdateByKey("1", { $set: { label: "x" } }),
253
+ NotFoundError,
254
+ );
255
+ });
256
+
257
+ it("replaces atomically, keeping the stored created and stamping updated", async () => {
258
+ const callerCreated = new Date("2025-01-01");
259
+ const object: TestModel = {
260
+ _id: "1",
261
+ label: "next",
262
+ created: callerCreated,
263
+ updated,
264
+ };
265
+
266
+ const result = await createStore().replaceOneWithInfo(object);
267
+
268
+ const writeTime = assertWriteTime(
269
+ replacementStampOf(findOneAndUpdateCalls[0]?.[1]),
270
+ );
271
+ const replacement = { ...object, updated: writeTime };
272
+ assert.deepEqual(findOneAndUpdateCalls, [
273
+ [
274
+ { _id: "1" },
275
+ createReplacePipeline(replacement),
276
+ { returnDocument: "before" },
277
+ ],
278
+ ]);
279
+ assert.deepEqual(result, {
280
+ prev: stored,
281
+ next: { ...replacement, created },
282
+ });
283
+ assert.equal(object.updated, updated);
284
+ });
285
+
286
+ it("partially updates in one call returning the document after the write", async () => {
287
+ existingDocument = next;
288
+
289
+ const result = await createStore().partialUpdateByKey("1", {
290
+ $set: { label: "next" },
291
+ });
292
+
293
+ const writeTime = assertWriteTime(stampOf(findOneAndUpdateCalls[0]?.[1]));
294
+ assert.deepEqual(findOneAndUpdateCalls, [
295
+ [
296
+ { _id: "1" },
297
+ { $set: { label: "next", updated: writeTime } },
298
+ { returnDocument: "after" },
299
+ ],
300
+ ]);
301
+ assert.deepEqual(result, next);
302
+ });
303
+
304
+ it("replaceOne returns the stored document and leaves the caller's object untouched", async () => {
305
+ existingDocument = next;
306
+ const object: TestModel = { _id: "1", label: "next", created, updated };
307
+
308
+ const result = await createStore().replaceOne(object);
309
+
310
+ const writeTime = assertWriteTime(
311
+ replacementStampOf(findOneAndUpdateCalls[0]?.[1]),
312
+ );
313
+ assert.deepEqual(findOneAndUpdateCalls, [
314
+ [
315
+ { _id: "1" },
316
+ createReplacePipeline({ ...object, updated: writeTime }),
317
+ { returnDocument: "after" },
318
+ ],
319
+ ]);
320
+ assert.equal(result, next);
321
+ assert.equal(object.updated, updated);
322
+ });
323
+
324
+ it("throws NotFoundError when replacing a missing document", async () => {
325
+ existingDocument = null;
326
+
327
+ await assert.rejects(
328
+ createStore().replaceOne({ _id: "1", label: "x", created, updated }),
329
+ NotFoundError,
330
+ );
331
+ });
332
+
333
+ it("deletes atomically with the criteria and returns prev", async () => {
334
+ const result = await createStore().deleteByKeyWithInfo("1", {
335
+ status: "open",
336
+ });
337
+
338
+ assert.deepEqual(findOneAndDeleteCalls, [[{ _id: "1", status: "open" }]]);
339
+ assert.deepEqual(result, { prev: stored });
340
+ });
341
+
342
+ it("resolves when deleting a missing document", async () => {
343
+ existingDocument = null;
344
+
345
+ const store = createStore();
346
+
347
+ assert.equal(await store.deleteByKeyWithInfo("1"), undefined);
348
+ await store.deleteByKey("1");
349
+ });
100
350
  });