liwi-mongo 13.4.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/definitions/MongoCursor.d.ts.map +1 -1
- package/dist/definitions/MongoQuerySingleItem.d.ts +1 -0
- package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
- package/dist/definitions/MongoQuerySingleItem.test.d.ts +2 -0
- package/dist/definitions/MongoQuerySingleItem.test.d.ts.map +1 -0
- package/dist/definitions/MongoStore.d.ts +18 -4
- package/dist/definitions/MongoStore.d.ts.map +1 -1
- package/dist/definitions/SubscribeStore.integration.test.d.ts +2 -0
- package/dist/definitions/SubscribeStore.integration.test.d.ts.map +1 -0
- package/dist/definitions/writes/createReplacePipeline.d.ts +10 -0
- package/dist/definitions/writes/createReplacePipeline.d.ts.map +1 -0
- package/dist/definitions/writes/stampPartialUpdate.d.ts +9 -0
- package/dist/definitions/writes/stampPartialUpdate.d.ts.map +1 -0
- package/dist/definitions/writes/stampPartialUpdate.test.d.ts +2 -0
- package/dist/definitions/writes/stampPartialUpdate.test.d.ts.map +1 -0
- package/dist/index-node.mjs +205 -81
- package/dist/index-node.mjs.map +1 -1
- package/package.json +5 -5
- package/src/MongoCursor.ts +2 -4
- package/src/MongoQuerySingleItem.test.ts +162 -0
- package/src/MongoQuerySingleItem.ts +56 -50
- package/src/MongoStore.test.ts +250 -0
- package/src/MongoStore.ts +122 -26
- package/src/SubscribeStore.integration.test.ts +414 -0
- package/src/indexes/diffIndexes.test.ts +2 -2
- package/src/indexes/normalizeIndex.test.ts +2 -2
- package/src/indexes/normalizeIndex.ts +2 -2
- package/src/writes/createReplacePipeline.ts +23 -0
- package/src/writes/stampPartialUpdate.test.ts +93 -0
- package/src/writes/stampPartialUpdate.ts +84 -0
|
@@ -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
|
+
};
|