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.
- package/README.md +27 -2
- package/dist/definitions/MongoConnection.d.ts +7 -1
- package/dist/definitions/MongoConnection.d.ts.map +1 -1
- package/dist/definitions/MongoConnection.test.d.ts +2 -0
- package/dist/definitions/MongoConnection.test.d.ts.map +1 -0
- 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 +225 -93
- package/dist/index-node.mjs.map +1 -1
- package/package.json +9 -8
- package/src/MongoConnection.test.ts +75 -0
- package/src/MongoConnection.ts +38 -25
- package/src/MongoCursor.ts +2 -4
- package/src/MongoQueryCollection.test.ts +9 -23
- package/src/MongoQueryCollection.ts +2 -2
- package/src/MongoQuerySingleItem.test.ts +162 -0
- package/src/MongoQuerySingleItem.ts +58 -52
- package/src/MongoStore.test.ts +259 -18
- package/src/MongoStore.ts +122 -26
- package/src/MongoStoreIndexes.test.ts +10 -24
- 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.test.ts
CHANGED
|
@@ -1,6 +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";
|
|
4
|
+
import mongodb from "mongodb";
|
|
3
5
|
import type { MongoBaseModel } from "./MongoBaseModel.ts";
|
|
6
|
+
import MongoConnection from "./MongoConnection.ts";
|
|
7
|
+
import MongoStore from "./MongoStore.ts";
|
|
8
|
+
import { createReplacePipeline } from "./writes/createReplacePipeline.ts";
|
|
4
9
|
|
|
5
10
|
interface TestModel extends MongoBaseModel {
|
|
6
11
|
label: string;
|
|
@@ -8,33 +13,34 @@ interface TestModel extends MongoBaseModel {
|
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
let findOneAndUpdateCalls: unknown[][];
|
|
16
|
+
let findOneAndDeleteCalls: unknown[][];
|
|
17
|
+
let updateManyCalls: unknown[][];
|
|
11
18
|
let existingDocument: TestModel | null;
|
|
19
|
+
let documentAfterWrite: TestModel | null;
|
|
12
20
|
|
|
13
21
|
const collection = {
|
|
14
22
|
findOneAndUpdate: (...args: unknown[]) => {
|
|
15
23
|
findOneAndUpdateCalls.push(args);
|
|
16
24
|
return Promise.resolve(existingDocument);
|
|
17
25
|
},
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
mock.module("mongodb", {
|
|
22
|
-
// @ts-expect-error: not yet typed
|
|
23
|
-
exports: {
|
|
24
|
-
default: {
|
|
25
|
-
MongoClient: {
|
|
26
|
-
connect: () =>
|
|
27
|
-
Promise.resolve({
|
|
28
|
-
on: () => undefined,
|
|
29
|
-
db: () => ({ collection: () => collection }),
|
|
30
|
-
}),
|
|
31
|
-
},
|
|
32
|
-
},
|
|
26
|
+
findOneAndDelete: (...args: unknown[]) => {
|
|
27
|
+
findOneAndDeleteCalls.push(args);
|
|
28
|
+
return Promise.resolve(existingDocument);
|
|
33
29
|
},
|
|
34
|
-
|
|
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 }),
|
|
36
|
+
};
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
mock.method(mongodb.MongoClient, "connect", () =>
|
|
39
|
+
Promise.resolve({
|
|
40
|
+
on: () => undefined,
|
|
41
|
+
db: () => ({ collection: () => collection }),
|
|
42
|
+
} as unknown as mongodb.MongoClient),
|
|
43
|
+
);
|
|
38
44
|
|
|
39
45
|
const createStore = (): InstanceType<typeof MongoStore<TestModel>> =>
|
|
40
46
|
new MongoStore<TestModel>(new MongoConnection({ database: "test" }), "tasks");
|
|
@@ -106,4 +112,239 @@ describe("MongoStore upsertOneWithInfo", () => {
|
|
|
106
112
|
prev: existingDocument,
|
|
107
113
|
});
|
|
108
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
|
+
});
|
|
109
350
|
});
|
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> {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { beforeEach, describe, it, mock } from "node:test";
|
|
3
|
+
import mongodb from "mongodb";
|
|
3
4
|
import type { IndexDescription, IndexDescriptionInfo } from "mongodb";
|
|
4
5
|
import type { MongoBaseModel } from "./MongoBaseModel.ts";
|
|
6
|
+
import MongoConnection from "./MongoConnection.ts";
|
|
7
|
+
import MongoRegistry from "./MongoRegistry.ts";
|
|
5
8
|
import type { MongoStoreOptions } from "./MongoStore.ts";
|
|
9
|
+
import MongoStore from "./MongoStore.ts";
|
|
6
10
|
|
|
7
11
|
interface TestModel extends MongoBaseModel {
|
|
8
12
|
completed: boolean;
|
|
@@ -47,30 +51,12 @@ const db = {
|
|
|
47
51
|
},
|
|
48
52
|
};
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
connect: () =>
|
|
57
|
-
Promise.resolve({
|
|
58
|
-
on: () => undefined,
|
|
59
|
-
db: () => db,
|
|
60
|
-
}),
|
|
61
|
-
},
|
|
62
|
-
ObjectId: class {
|
|
63
|
-
toString(): string {
|
|
64
|
-
return "generated-id";
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const { default: MongoConnection } = await import("./MongoConnection.ts");
|
|
72
|
-
const { default: MongoStore } = await import("./MongoStore.ts");
|
|
73
|
-
const { default: MongoRegistry } = await import("./MongoRegistry.ts");
|
|
54
|
+
mock.method(mongodb.MongoClient, "connect", () =>
|
|
55
|
+
Promise.resolve({
|
|
56
|
+
on: () => undefined,
|
|
57
|
+
db: () => db,
|
|
58
|
+
} as unknown as mongodb.MongoClient),
|
|
59
|
+
);
|
|
74
60
|
|
|
75
61
|
const createStore = (
|
|
76
62
|
options?: MongoStoreOptions<TestModel>,
|