@warlock.js/cascade 4.15.0 → 4.16.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/CHANGELOG.md +13 -0
- package/cjs/index.cjs +494 -72
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/query-builder.contract.d.mts +7 -3
- package/esm/contracts/query-builder.contract.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +4 -3
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts +21 -0
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.mjs +55 -22
- package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
- package/esm/errors/unsafe-filter.error.d.mts +33 -0
- package/esm/errors/unsafe-filter.error.d.mts.map +1 -0
- package/esm/errors/unsafe-filter.error.mjs +40 -0
- package/esm/errors/unsafe-filter.error.mjs.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts +23 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.mjs +28 -0
- package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -0
- package/esm/index.d.mts +5 -1
- package/esm/index.mjs +5 -1
- package/esm/model/methods/accessor-methods.mjs +39 -1
- package/esm/model/methods/accessor-methods.mjs.map +1 -1
- package/esm/model/methods/delete-methods.mjs +3 -2
- package/esm/model/methods/delete-methods.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +7 -4
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/methods/serialization-methods.mjs +49 -4
- package/esm/model/methods/serialization-methods.mjs.map +1 -1
- package/esm/model/methods/write-methods.d.mts.map +1 -1
- package/esm/model/methods/write-methods.mjs +2 -4
- package/esm/model/methods/write-methods.mjs.map +1 -1
- package/esm/model/model.d.mts +47 -1
- package/esm/model/model.d.mts.map +1 -1
- package/esm/model/model.mjs +61 -2
- package/esm/model/model.mjs.map +1 -1
- package/esm/model/model.types.d.mts +1 -1
- package/esm/query-builder/query-builder.d.mts +13 -1
- package/esm/query-builder/query-builder.d.mts.map +1 -1
- package/esm/query-builder/query-builder.mjs +28 -11
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/esm/remover/database-remover.d.mts.map +1 -1
- package/esm/remover/database-remover.mjs +1 -1
- package/esm/remover/database-remover.mjs.map +1 -1
- package/esm/utils/escape-regex.d.mts +67 -0
- package/esm/utils/escape-regex.d.mts.map +1 -0
- package/esm/utils/escape-regex.mjs +76 -0
- package/esm/utils/escape-regex.mjs.map +1 -0
- package/esm/utils/sanitize-filter.d.mts +26 -0
- package/esm/utils/sanitize-filter.d.mts.map +1 -0
- package/esm/utils/sanitize-filter.mjs +76 -0
- package/esm/utils/sanitize-filter.mjs.map +1 -0
- package/esm/writer/database-writer.d.mts +12 -0
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +26 -6
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms-full.txt +59 -4
- package/llms.txt +3 -3
- package/package.json +8 -8
- package/skills/README.md +3 -3
- package/skills/define-model/SKILL.md +17 -1
- package/skills/perform-atomic-ops/SKILL.md +4 -1
- package/skills/query-data/SKILL.md +38 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region ../cascade/src/utils/escape-regex.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Regex escaping for pattern-matching helpers.
|
|
4
|
+
*
|
|
5
|
+
* `whereLike` / `whereNotLike` / `whereStartsWith` / `whereEndsWith` /
|
|
6
|
+
* `whereSearch` compile their argument into a MongoDB `$regex`. The argument is
|
|
7
|
+
* exactly what a search box hands over (`whereSearch("name", req.query.q)`), so
|
|
8
|
+
* passing it through unescaped gave the caller control of the regex itself:
|
|
9
|
+
*
|
|
10
|
+
* - **injection** — `.*` / `|` / `^` change the intended match semantics, and a
|
|
11
|
+
* boolean-oracle probe (`^a`, `^b`, …) reads values back one character at a
|
|
12
|
+
* time from a field the endpoint never meant to expose;
|
|
13
|
+
* - **ReDoS** — nested quantifiers (`(a+)+`) backtrack catastrophically inside
|
|
14
|
+
* `mongod`, against every document the query scans.
|
|
15
|
+
*
|
|
16
|
+
* A string value is therefore treated as a LITERAL. Only an explicit `RegExp`
|
|
17
|
+
* argument — which cannot come from JSON, so it is developer-authored — reaches
|
|
18
|
+
* the regex engine as a pattern.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Escape every regex metacharacter in a string so it matches itself.
|
|
22
|
+
*
|
|
23
|
+
* @param value - The literal text to match
|
|
24
|
+
* @returns Regex source matching `value` verbatim
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* escapeRegex("(a+)+$"); // "\\(a\\+\\)\\+\\$"
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
declare function escapeRegex(value: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Compile a `whereLike` pattern into regex source.
|
|
34
|
+
*
|
|
35
|
+
* The pattern is escaped first, so nothing the caller typed can act as a regex
|
|
36
|
+
* operator; the SQL `LIKE` wildcard `%` is then translated to `.*` — the one
|
|
37
|
+
* wildcard the API documents (`whereLike("email", "%@gmail.com")`). Runs of `%`
|
|
38
|
+
* collapse into a single `.*`, since `%%%%…` compiles to nothing but extra
|
|
39
|
+
* backtracking work.
|
|
40
|
+
*
|
|
41
|
+
* The result stays unanchored: MongoDB's `whereLike` matches a substring
|
|
42
|
+
* (`whereLike("name", "ar")` finds "Carol"), which is the documented behavior of
|
|
43
|
+
* this driver and is unchanged by the escaping.
|
|
44
|
+
*
|
|
45
|
+
* @param pattern - The user-supplied LIKE pattern
|
|
46
|
+
* @returns Regex source matching the pattern literally, `%` aside
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* likePatternToRegexSource("%o'brien%"); // ".*o'brien.*"
|
|
51
|
+
* likePatternToRegexSource("a.b"); // "a\\.b" (a literal dot)
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
declare function likePatternToRegexSource(pattern: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a `whereLike`-style argument to regex source.
|
|
57
|
+
*
|
|
58
|
+
* An explicit `RegExp` is developer-authored and passes through as-is; a string
|
|
59
|
+
* is user-shaped input and is treated as a literal LIKE pattern.
|
|
60
|
+
*
|
|
61
|
+
* @param pattern - A `RegExp` (trusted, used verbatim) or a string (escaped)
|
|
62
|
+
* @returns Regex source ready for `$regex`
|
|
63
|
+
*/
|
|
64
|
+
declare function resolveLikePattern(pattern: RegExp | string): string;
|
|
65
|
+
//#endregion
|
|
66
|
+
export { escapeRegex, likePatternToRegexSource, resolveLikePattern };
|
|
67
|
+
//# sourceMappingURL=escape-regex.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-regex.d.mts","names":[],"sources":["../../../../../../../cascade/src/utils/escape-regex.ts"],"mappings":";;AAiCA;;;;AAAyC;AA0BzC;;;;AAAwD;AAaxD;;;;AAA2D;;;;;;;;;;;;;;iBAvC3C,WAAA,CAAY,KAAa;;;;;;;;;;;;;;;;;;;;;;;iBA0BzB,wBAAA,CAAyB,OAAe;;;;;;;;;;iBAaxC,kBAAA,CAAmB,OAAwB,EAAf,MAAM"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region ../cascade/src/utils/escape-regex.ts
|
|
2
|
+
/**
|
|
3
|
+
* Regex escaping for pattern-matching helpers.
|
|
4
|
+
*
|
|
5
|
+
* `whereLike` / `whereNotLike` / `whereStartsWith` / `whereEndsWith` /
|
|
6
|
+
* `whereSearch` compile their argument into a MongoDB `$regex`. The argument is
|
|
7
|
+
* exactly what a search box hands over (`whereSearch("name", req.query.q)`), so
|
|
8
|
+
* passing it through unescaped gave the caller control of the regex itself:
|
|
9
|
+
*
|
|
10
|
+
* - **injection** — `.*` / `|` / `^` change the intended match semantics, and a
|
|
11
|
+
* boolean-oracle probe (`^a`, `^b`, …) reads values back one character at a
|
|
12
|
+
* time from a field the endpoint never meant to expose;
|
|
13
|
+
* - **ReDoS** — nested quantifiers (`(a+)+`) backtrack catastrophically inside
|
|
14
|
+
* `mongod`, against every document the query scans.
|
|
15
|
+
*
|
|
16
|
+
* A string value is therefore treated as a LITERAL. Only an explicit `RegExp`
|
|
17
|
+
* argument — which cannot come from JSON, so it is developer-authored — reaches
|
|
18
|
+
* the regex engine as a pattern.
|
|
19
|
+
*/
|
|
20
|
+
/** Characters that carry meaning to the regex engine and must be neutralized. */
|
|
21
|
+
const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
|
|
22
|
+
/**
|
|
23
|
+
* Escape every regex metacharacter in a string so it matches itself.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The literal text to match
|
|
26
|
+
* @returns Regex source matching `value` verbatim
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* escapeRegex("(a+)+$"); // "\\(a\\+\\)\\+\\$"
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
function escapeRegex(value) {
|
|
34
|
+
return value.replace(REGEX_METACHARACTERS, "\\$&");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compile a `whereLike` pattern into regex source.
|
|
38
|
+
*
|
|
39
|
+
* The pattern is escaped first, so nothing the caller typed can act as a regex
|
|
40
|
+
* operator; the SQL `LIKE` wildcard `%` is then translated to `.*` — the one
|
|
41
|
+
* wildcard the API documents (`whereLike("email", "%@gmail.com")`). Runs of `%`
|
|
42
|
+
* collapse into a single `.*`, since `%%%%…` compiles to nothing but extra
|
|
43
|
+
* backtracking work.
|
|
44
|
+
*
|
|
45
|
+
* The result stays unanchored: MongoDB's `whereLike` matches a substring
|
|
46
|
+
* (`whereLike("name", "ar")` finds "Carol"), which is the documented behavior of
|
|
47
|
+
* this driver and is unchanged by the escaping.
|
|
48
|
+
*
|
|
49
|
+
* @param pattern - The user-supplied LIKE pattern
|
|
50
|
+
* @returns Regex source matching the pattern literally, `%` aside
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* likePatternToRegexSource("%o'brien%"); // ".*o'brien.*"
|
|
55
|
+
* likePatternToRegexSource("a.b"); // "a\\.b" (a literal dot)
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
function likePatternToRegexSource(pattern) {
|
|
59
|
+
return escapeRegex(pattern).replace(/%+/g, ".*");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a `whereLike`-style argument to regex source.
|
|
63
|
+
*
|
|
64
|
+
* An explicit `RegExp` is developer-authored and passes through as-is; a string
|
|
65
|
+
* is user-shaped input and is treated as a literal LIKE pattern.
|
|
66
|
+
*
|
|
67
|
+
* @param pattern - A `RegExp` (trusted, used verbatim) or a string (escaped)
|
|
68
|
+
* @returns Regex source ready for `$regex`
|
|
69
|
+
*/
|
|
70
|
+
function resolveLikePattern(pattern) {
|
|
71
|
+
return pattern instanceof RegExp ? pattern.source : likePatternToRegexSource(pattern);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { escapeRegex, likePatternToRegexSource, resolveLikePattern };
|
|
76
|
+
//# sourceMappingURL=escape-regex.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-regex.mjs","names":[],"sources":["../../../../../../../cascade/src/utils/escape-regex.ts"],"sourcesContent":["/**\n * Regex escaping for pattern-matching helpers.\n *\n * `whereLike` / `whereNotLike` / `whereStartsWith` / `whereEndsWith` /\n * `whereSearch` compile their argument into a MongoDB `$regex`. The argument is\n * exactly what a search box hands over (`whereSearch(\"name\", req.query.q)`), so\n * passing it through unescaped gave the caller control of the regex itself:\n *\n * - **injection** — `.*` / `|` / `^` change the intended match semantics, and a\n * boolean-oracle probe (`^a`, `^b`, …) reads values back one character at a\n * time from a field the endpoint never meant to expose;\n * - **ReDoS** — nested quantifiers (`(a+)+`) backtrack catastrophically inside\n * `mongod`, against every document the query scans.\n *\n * A string value is therefore treated as a LITERAL. Only an explicit `RegExp`\n * argument — which cannot come from JSON, so it is developer-authored — reaches\n * the regex engine as a pattern.\n */\n\n/** Characters that carry meaning to the regex engine and must be neutralized. */\nconst REGEX_METACHARACTERS = /[.*+?^${}()|[\\]\\\\]/g;\n\n/**\n * Escape every regex metacharacter in a string so it matches itself.\n *\n * @param value - The literal text to match\n * @returns Regex source matching `value` verbatim\n *\n * @example\n * ```typescript\n * escapeRegex(\"(a+)+$\"); // \"\\\\(a\\\\+\\\\)\\\\+\\\\$\"\n * ```\n */\nexport function escapeRegex(value: string): string {\n return value.replace(REGEX_METACHARACTERS, \"\\\\$&\");\n}\n\n/**\n * Compile a `whereLike` pattern into regex source.\n *\n * The pattern is escaped first, so nothing the caller typed can act as a regex\n * operator; the SQL `LIKE` wildcard `%` is then translated to `.*` — the one\n * wildcard the API documents (`whereLike(\"email\", \"%@gmail.com\")`). Runs of `%`\n * collapse into a single `.*`, since `%%%%…` compiles to nothing but extra\n * backtracking work.\n *\n * The result stays unanchored: MongoDB's `whereLike` matches a substring\n * (`whereLike(\"name\", \"ar\")` finds \"Carol\"), which is the documented behavior of\n * this driver and is unchanged by the escaping.\n *\n * @param pattern - The user-supplied LIKE pattern\n * @returns Regex source matching the pattern literally, `%` aside\n *\n * @example\n * ```typescript\n * likePatternToRegexSource(\"%o'brien%\"); // \".*o'brien.*\"\n * likePatternToRegexSource(\"a.b\"); // \"a\\\\.b\" (a literal dot)\n * ```\n */\nexport function likePatternToRegexSource(pattern: string): string {\n return escapeRegex(pattern).replace(/%+/g, \".*\");\n}\n\n/**\n * Resolve a `whereLike`-style argument to regex source.\n *\n * An explicit `RegExp` is developer-authored and passes through as-is; a string\n * is user-shaped input and is treated as a literal LIKE pattern.\n *\n * @param pattern - A `RegExp` (trusted, used verbatim) or a string (escaped)\n * @returns Regex source ready for `$regex`\n */\nexport function resolveLikePattern(pattern: RegExp | string): string {\n return pattern instanceof RegExp ? pattern.source : likePatternToRegexSource(pattern);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAoBA,MAAM,uBAAuB;;;;;;;;;;;;AAa7B,SAAgB,YAAY,OAAuB;CACjD,OAAO,MAAM,QAAQ,sBAAsB,MAAM;AACnD;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,yBAAyB,SAAyB;CAChE,OAAO,YAAY,OAAO,CAAC,CAAC,QAAQ,OAAO,IAAI;AACjD;;;;;;;;;;AAWA,SAAgB,mBAAmB,SAAkC;CACnE,OAAO,mBAAmB,SAAS,QAAQ,SAAS,yBAAyB,OAAO;AACtF"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region ../cascade/src/utils/sanitize-filter.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Assert a single equality-position value carries no `$`-prefixed keys.
|
|
4
|
+
* Scalars, `Date`s and other non-plain objects pass through untouched;
|
|
5
|
+
* plain objects/arrays are checked recursively.
|
|
6
|
+
*
|
|
7
|
+
* @param value - The equality value to check
|
|
8
|
+
* @param field - The field name, used for the error message
|
|
9
|
+
* @returns The value, unchanged
|
|
10
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
11
|
+
*/
|
|
12
|
+
declare function sanitizeFilterValue<T>(value: T, field: string): T;
|
|
13
|
+
/**
|
|
14
|
+
* Assert a `{ field: value }` equality filter carries no `$`-prefixed keys —
|
|
15
|
+
* neither as top-level field names (`{ $where: … }`) nor inside any value
|
|
16
|
+
* (`{ password: { $ne: null } }`). Dotted field paths ("profile.name") and
|
|
17
|
+
* plain nested documents remain valid.
|
|
18
|
+
*
|
|
19
|
+
* @param filter - The filter object to check
|
|
20
|
+
* @returns The filter, unchanged
|
|
21
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
22
|
+
*/
|
|
23
|
+
declare function sanitizeFilter<T extends Record<string, unknown>>(filter: T): T;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { sanitizeFilter, sanitizeFilterValue };
|
|
26
|
+
//# sourceMappingURL=sanitize-filter.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-filter.d.mts","names":[],"sources":["../../../../../../../cascade/src/utils/sanitize-filter.ts"],"mappings":";;AAkEA;;;;;;;;;iBAAgB,mBAAA,IAAuB,KAAA,EAAO,CAAA,EAAG,KAAA,WAAgB,CAAC;AAAA;AAelE;;;;;;;;;AAfkE,iBAelD,cAAA,WAAyB,MAAA,mBAAyB,MAAA,EAAQ,CAAA,GAAI,CAAA"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { UnsafeFilterError } from "../errors/unsafe-filter.error.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../cascade/src/utils/sanitize-filter.ts
|
|
4
|
+
/**
|
|
5
|
+
* Filter sanitization for equality-position values.
|
|
6
|
+
*
|
|
7
|
+
* `where({ field: value })`, `where(field, value)` and the filter-accepting
|
|
8
|
+
* model statics (`first`, `findAll`, `deleteMany`, …) express *equality*
|
|
9
|
+
* matches. If a request-controlled value such as `{ $ne: null }` is passed
|
|
10
|
+
* through verbatim, MongoDB reinterprets it as an operator query — the classic
|
|
11
|
+
* NoSQL operator-injection / auth-bypass primitive. These helpers reject any
|
|
12
|
+
* `$`-prefixed key found in an equality position instead of forwarding it.
|
|
13
|
+
*
|
|
14
|
+
* Explicit operator APIs (`where(field, operator, value)`, `whereIn`,
|
|
15
|
+
* `whereNull`, object-form `whereRaw({ ... })`, …) are intentionally NOT
|
|
16
|
+
* routed through this check.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Returns true for plain objects only (`{}` / `Object.create(null)`).
|
|
20
|
+
* BSON values such as `Date`, `RegExp`, `ObjectId` or `Buffer` are class
|
|
21
|
+
* instances whose keys are not Mongo operators, so they are never traversed.
|
|
22
|
+
*/
|
|
23
|
+
const isPlainObject = (value) => {
|
|
24
|
+
if (value === null || typeof value !== "object") return false;
|
|
25
|
+
const prototype = Object.getPrototypeOf(value);
|
|
26
|
+
return prototype === Object.prototype || prototype === null;
|
|
27
|
+
};
|
|
28
|
+
const rejectOperatorKey = (field, key) => {
|
|
29
|
+
throw new UnsafeFilterError(`Unsafe filter: value for field "${field}" contains the reserved MongoDB operator key "${key}". Equality filters must not carry "$"-prefixed keys. Use the explicit operator API instead — e.g. where(field, operator, value), whereIn(), whereNull(), or the object form of whereRaw().`, field, key);
|
|
30
|
+
};
|
|
31
|
+
const assertNoOperatorKeys = (value, field) => {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
for (const item of value) assertNoOperatorKeys(item, field);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(value)) return;
|
|
37
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
38
|
+
if (key.startsWith("$")) rejectOperatorKey(field, key);
|
|
39
|
+
assertNoOperatorKeys(nested, field);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Assert a single equality-position value carries no `$`-prefixed keys.
|
|
44
|
+
* Scalars, `Date`s and other non-plain objects pass through untouched;
|
|
45
|
+
* plain objects/arrays are checked recursively.
|
|
46
|
+
*
|
|
47
|
+
* @param value - The equality value to check
|
|
48
|
+
* @param field - The field name, used for the error message
|
|
49
|
+
* @returns The value, unchanged
|
|
50
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
51
|
+
*/
|
|
52
|
+
function sanitizeFilterValue(value, field) {
|
|
53
|
+
assertNoOperatorKeys(value, field);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Assert a `{ field: value }` equality filter carries no `$`-prefixed keys —
|
|
58
|
+
* neither as top-level field names (`{ $where: … }`) nor inside any value
|
|
59
|
+
* (`{ password: { $ne: null } }`). Dotted field paths ("profile.name") and
|
|
60
|
+
* plain nested documents remain valid.
|
|
61
|
+
*
|
|
62
|
+
* @param filter - The filter object to check
|
|
63
|
+
* @returns The filter, unchanged
|
|
64
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeFilter(filter) {
|
|
67
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
68
|
+
if (field.startsWith("$")) rejectOperatorKey(field, field);
|
|
69
|
+
assertNoOperatorKeys(value, field);
|
|
70
|
+
}
|
|
71
|
+
return filter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { sanitizeFilter, sanitizeFilterValue };
|
|
76
|
+
//# sourceMappingURL=sanitize-filter.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-filter.mjs","names":[],"sources":["../../../../../../../cascade/src/utils/sanitize-filter.ts"],"sourcesContent":["import { UnsafeFilterError } from \"../errors/unsafe-filter.error\";\r\n\r\n/**\r\n * Filter sanitization for equality-position values.\r\n *\r\n * `where({ field: value })`, `where(field, value)` and the filter-accepting\r\n * model statics (`first`, `findAll`, `deleteMany`, …) express *equality*\r\n * matches. If a request-controlled value such as `{ $ne: null }` is passed\r\n * through verbatim, MongoDB reinterprets it as an operator query — the classic\r\n * NoSQL operator-injection / auth-bypass primitive. These helpers reject any\r\n * `$`-prefixed key found in an equality position instead of forwarding it.\r\n *\r\n * Explicit operator APIs (`where(field, operator, value)`, `whereIn`,\r\n * `whereNull`, object-form `whereRaw({ ... })`, …) are intentionally NOT\r\n * routed through this check.\r\n */\r\n\r\n/**\r\n * Returns true for plain objects only (`{}` / `Object.create(null)`).\r\n * BSON values such as `Date`, `RegExp`, `ObjectId` or `Buffer` are class\r\n * instances whose keys are not Mongo operators, so they are never traversed.\r\n */\r\nconst isPlainObject = (value: unknown): value is Record<string, unknown> => {\r\n if (value === null || typeof value !== \"object\") return false;\r\n const prototype = Object.getPrototypeOf(value);\r\n return prototype === Object.prototype || prototype === null;\r\n};\r\n\r\nconst rejectOperatorKey = (field: string, key: string): never => {\r\n throw new UnsafeFilterError(\r\n `Unsafe filter: value for field \"${field}\" contains the reserved MongoDB operator key \"${key}\". ` +\r\n `Equality filters must not carry \"$\"-prefixed keys. ` +\r\n `Use the explicit operator API instead — e.g. where(field, operator, value), whereIn(), whereNull(), or the object form of whereRaw().`,\r\n field,\r\n key,\r\n );\r\n};\r\n\r\nconst assertNoOperatorKeys = (value: unknown, field: string): void => {\r\n if (Array.isArray(value)) {\r\n for (const item of value) {\r\n assertNoOperatorKeys(item, field);\r\n }\r\n return;\r\n }\r\n\r\n if (!isPlainObject(value)) return;\r\n\r\n for (const [key, nested] of Object.entries(value)) {\r\n if (key.startsWith(\"$\")) {\r\n rejectOperatorKey(field, key);\r\n }\r\n assertNoOperatorKeys(nested, field);\r\n }\r\n};\r\n\r\n/**\r\n * Assert a single equality-position value carries no `$`-prefixed keys.\r\n * Scalars, `Date`s and other non-plain objects pass through untouched;\r\n * plain objects/arrays are checked recursively.\r\n *\r\n * @param value - The equality value to check\r\n * @param field - The field name, used for the error message\r\n * @returns The value, unchanged\r\n * @throws UnsafeFilterError when a `$`-prefixed key is found\r\n */\r\nexport function sanitizeFilterValue<T>(value: T, field: string): T {\r\n assertNoOperatorKeys(value, field);\r\n return value;\r\n}\r\n\r\n/**\r\n * Assert a `{ field: value }` equality filter carries no `$`-prefixed keys —\r\n * neither as top-level field names (`{ $where: … }`) nor inside any value\r\n * (`{ password: { $ne: null } }`). Dotted field paths (\"profile.name\") and\r\n * plain nested documents remain valid.\r\n *\r\n * @param filter - The filter object to check\r\n * @returns The filter, unchanged\r\n * @throws UnsafeFilterError when a `$`-prefixed key is found\r\n */\r\nexport function sanitizeFilter<T extends Record<string, unknown>>(filter: T): T {\r\n for (const [field, value] of Object.entries(filter)) {\r\n if (field.startsWith(\"$\")) {\r\n rejectOperatorKey(field, field);\r\n }\r\n assertNoOperatorKeys(value, field);\r\n }\r\n return filter;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,iBAAiB,UAAqD;CAC1E,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO;CACxD,MAAM,YAAY,OAAO,eAAe,KAAK;CAC7C,OAAO,cAAc,OAAO,aAAa,cAAc;AACzD;AAEA,MAAM,qBAAqB,OAAe,QAAuB;CAC/D,MAAM,IAAI,kBACR,mCAAmC,MAAM,gDAAgD,IAAI,8LAG7F,OACA,GACF;AACF;AAEA,MAAM,wBAAwB,OAAgB,UAAwB;CACpE,IAAI,MAAM,QAAQ,KAAK,GAAG;EACxB,KAAK,MAAM,QAAQ,OACjB,qBAAqB,MAAM,KAAK;EAElC;CACF;CAEA,IAAI,CAAC,cAAc,KAAK,GAAG;CAE3B,KAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,KAAK,GAAG;EACjD,IAAI,IAAI,WAAW,GAAG,GACpB,kBAAkB,OAAO,GAAG;EAE9B,qBAAqB,QAAQ,KAAK;CACpC;AACF;;;;;;;;;;;AAYA,SAAgB,oBAAuB,OAAU,OAAkB;CACjE,qBAAqB,OAAO,KAAK;CACjC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,eAAkD,QAAc;CAC9E,KAAK,MAAM,CAAC,OAAO,UAAU,OAAO,QAAQ,MAAM,GAAG;EACnD,IAAI,MAAM,WAAW,GAAG,GACtB,kBAAkB,OAAO,KAAK;EAEhC,qBAAqB,OAAO,KAAK;CACnC;CACA,OAAO;AACT"}
|
|
@@ -101,6 +101,18 @@ declare class DatabaseWriter implements WriterContract {
|
|
|
101
101
|
* @private
|
|
102
102
|
*/
|
|
103
103
|
private performUpdate;
|
|
104
|
+
/**
|
|
105
|
+
* Build the filter that pins a write to the row this model was loaded from.
|
|
106
|
+
*
|
|
107
|
+
* It reads `model.trustedPrimaryKey` — the value captured when the instance
|
|
108
|
+
* became persisted — NOT the current value in `model.data`. The current value
|
|
109
|
+
* is reachable by mass assignment (`model.merge(req.body)`), so deriving the
|
|
110
|
+
* filter from it let a request body redirect the UPDATE to another document.
|
|
111
|
+
*
|
|
112
|
+
* @returns Filter matching the originally loaded record
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
private buildPrimaryKeyFilter;
|
|
104
116
|
/**
|
|
105
117
|
* Generate ID for the model if auto-generation is enabled.
|
|
106
118
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"database-writer.d.mts","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"mappings":";;;;;;AAuDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,cAAA;EA+UN;EAAA,iBA7Ud,KAAA;EA6aT;EAAA,iBA1aS,IAAA;EAydT;EAAA,iBAtdS,UAAA;EAmeQ;EAAA,iBAheR,MAAA;;mBAGA,KAAA;;mBAGA,UAAA;;mBAGA,MAAA;;mBAGA,UAAA;;;;;;;;;;;;;cAcE,KAAA,EAAO,KAAA;;;;;;;;EAkBb,IAAA,CAAK,OAAA,GAAS,aAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;;UAsE1C,eAAA;;;;;;;;UA8FA,aAAA;;;;;;;;UAqDA,aAAA;;;;;;;;;;;;UAsDN,qBAAA;;;;;;EASK,cAAA,IAAkB,OAAA;;;;;;;;;;;;;;;;;;;;;;UA6CvB,qBAAA;;;;;;;;;;;;UAmDA,gBAAA;;;;;;;;;;;;UAyBA,kBAAA;;;;;;;;;UAsBA,SAAA;;;;;;;;;;UAaM,WAAA;AAAA"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getModelUpdatedEvent } from "../sync/model-events.mjs";
|
|
2
|
+
import { mergeDriverFields } from "../model/methods/accessor-methods.mjs";
|
|
2
3
|
import { DatabaseWriterValidationError } from "../validation/database-writer-validation-error.mjs";
|
|
3
4
|
import "../validation/index.mjs";
|
|
4
5
|
import { when } from "@mongez/reinforcements";
|
|
@@ -178,7 +179,7 @@ var DatabaseWriter = class {
|
|
|
178
179
|
if (updatedAtColumn) dataToInsert[updatedAtColumn] = /* @__PURE__ */ new Date();
|
|
179
180
|
if (!options.skipEvents) await this.model.emitEvent("creating");
|
|
180
181
|
const result = await this.driver.insert(this.table, dataToInsert);
|
|
181
|
-
this.model
|
|
182
|
+
mergeDriverFields(this.model, result.document);
|
|
182
183
|
this.model.dirtyTracker.reset();
|
|
183
184
|
return result;
|
|
184
185
|
}
|
|
@@ -194,13 +195,27 @@ var DatabaseWriter = class {
|
|
|
194
195
|
const updatedAtColumn = this.ctor.updatedAtColumn;
|
|
195
196
|
if (updatedAtColumn) this.model.set(updatedAtColumn, /* @__PURE__ */ new Date());
|
|
196
197
|
if (options.replace) {
|
|
197
|
-
const document = await this.driver.replace(this.table,
|
|
198
|
+
const document = await this.driver.replace(this.table, this.buildPrimaryKeyFilter(), this.model.data);
|
|
198
199
|
if (document) this.model.replaceData(document);
|
|
199
200
|
return { modifiedCount: document ? 1 : 0 };
|
|
200
201
|
}
|
|
201
202
|
const operations = this.buildUpdateOperations();
|
|
202
|
-
|
|
203
|
-
return await this.driver.update(this.table,
|
|
203
|
+
if (Object.keys(operations).length === 0) return { modifiedCount: 0 };
|
|
204
|
+
return await this.driver.update(this.table, this.buildPrimaryKeyFilter(), operations);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build the filter that pins a write to the row this model was loaded from.
|
|
208
|
+
*
|
|
209
|
+
* It reads `model.trustedPrimaryKey` — the value captured when the instance
|
|
210
|
+
* became persisted — NOT the current value in `model.data`. The current value
|
|
211
|
+
* is reachable by mass assignment (`model.merge(req.body)`), so deriving the
|
|
212
|
+
* filter from it let a request body redirect the UPDATE to another document.
|
|
213
|
+
*
|
|
214
|
+
* @returns Filter matching the originally loaded record
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
buildPrimaryKeyFilter() {
|
|
218
|
+
return { [this.primaryKey]: this.model.trustedPrimaryKey };
|
|
204
219
|
}
|
|
205
220
|
/**
|
|
206
221
|
* Generate ID for the model if auto-generation is enabled.
|
|
@@ -243,7 +258,12 @@ var DatabaseWriter = class {
|
|
|
243
258
|
*/
|
|
244
259
|
buildUpdateOperations() {
|
|
245
260
|
const operations = {};
|
|
246
|
-
const
|
|
261
|
+
const identityColumns = new Set([
|
|
262
|
+
this.primaryKey,
|
|
263
|
+
"id",
|
|
264
|
+
"_id"
|
|
265
|
+
]);
|
|
266
|
+
const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column));
|
|
247
267
|
if (dirtyColumns.length > 0) {
|
|
248
268
|
operations.$set = {};
|
|
249
269
|
for (const column of dirtyColumns) {
|
|
@@ -251,7 +271,7 @@ var DatabaseWriter = class {
|
|
|
251
271
|
operations.$set[column] = this.model.get(column);
|
|
252
272
|
}
|
|
253
273
|
}
|
|
254
|
-
const removedColumns = this.model.getRemovedColumns();
|
|
274
|
+
const removedColumns = this.model.getRemovedColumns().filter((column) => !identityColumns.has(column));
|
|
255
275
|
if (removedColumns.length > 0) {
|
|
256
276
|
operations.$unset = {};
|
|
257
277
|
for (const column of removedColumns) operations.$unset[column] = 1;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import events from \"@mongez/events\";\nimport { when } from \"@mongez/reinforcements\";\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\nimport type {\n DriverContract,\n InsertResult,\n UpdateOperations,\n UpdateResult,\n} from \"../contracts/database-driver.contract\";\nimport type {\n WriterContract,\n WriterOptions,\n WriterResult,\n} from \"../contracts/database-writer.contract\";\nimport type { ChildModel, Model } from \"../model/model\";\nimport { getModelUpdatedEvent } from \"../sync/model-events\";\nimport type { StrictMode } from \"../types\";\nimport { DatabaseWriterValidationError } from \"../validation\";\nimport type { DataSource } from \"./../data-source/data-source\";\n\n/**\n * Database writer service that orchestrates model persistence.\n *\n * Handles the complete save pipeline:\n * 1. Check for changes (skip if no changes and not new)\n * 2. Emit `saving` event (for data enrichment)\n * 3. Emit `validating` event\n * 4. Validate and cast data via @warlock.js/seal schema\n * 5. Emit `validated` event\n * 6. Generate ID (for new NoSQL records)\n * 7. Emit `creating`/`updating` events\n * 8. Execute insert or update via driver\n * 9. Merge returned data into model\n * 10. Reset dirty tracker and update `isNew` flag\n * 11. Emit `saved` and `created`/`updated` events\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n *\n * console.log(user.get(\"id\")); // 1 (auto-generated)\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\n *\n * // Update existing record\n * user.set(\"name\", \"Alice Smith\");\n * await writer.save();\n * // Only updates the \"name\" field (partial update)\n *\n * // Silent save (no events)\n * await writer.save({ skipEvents: true });\n * ```\n */\nexport class DatabaseWriter implements WriterContract {\n /** The model instance being persisted */\n private readonly model: Model;\n\n /** Model constructor reference */\n private readonly ctor: ChildModel<Model>;\n\n /** Data source containing driver and ID generator */\n private readonly dataSource: DataSource;\n\n /** Database driver for executing queries */\n private readonly driver: DriverContract;\n\n /** Table/collection name */\n private readonly table: string;\n\n /** Primary key field name */\n private readonly primaryKey: string;\n\n /** Validation schema (if defined) */\n private readonly schema?: ObjectValidator;\n\n /** Strict mode configuration */\n private readonly strictMode: StrictMode;\n\n /**\n * Create a new writer instance for a model.\n *\n * @param model - The model instance to persist\n *\n * @example\n * ```typescript\n * const user = new User({ name: \"Alice\" });\n * const writer = new DatabaseWriter(user);\n * await writer.save();\n * ```\n */\n public constructor(model: Model) {\n this.model = model;\n this.ctor = model.constructor as ChildModel<Model>;\n this.dataSource = this.ctor.getDataSource();\n this.driver = this.dataSource.driver;\n this.table = this.ctor.table;\n this.primaryKey = this.ctor.primaryKey;\n this.schema = this.ctor.schema;\n this.strictMode = this.ctor.strictMode;\n }\n\n /**\n * Save the model instance to the database.\n *\n * @param options - Save options\n * @returns Result with success status, document, and metadata\n * @throws {ValidationError} If validation fails\n */\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\n const isInsert = this.model.isNew;\n\n // 1. Check if model has changes (skip if no changes and not new)\n if (!isInsert && !this.model.hasChanges()) {\n return {\n success: true,\n document: this.model.data,\n isNew: false,\n modifiedCount: 0,\n };\n }\n\n // 2. Emit saving event (before validation for data enrichment)\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saving\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // 3. Validate and cast data\n await this.validateAndCast(isInsert, options);\n\n // 4. Execute insert or update\n let result: InsertResult | UpdateResult;\n\n if (isInsert) {\n result = await this.performInsert(options);\n } else {\n result = await this.performUpdate(options);\n }\n\n // 5. Reset dirty tracker and update isNew flag\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\n this.model.dirtyTracker.reset();\n this.model.isNew = false;\n\n // 6. Emit post-save events\n if (!options.skipEvents) {\n await this.model.emitEvent(\"saved\");\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\n }\n\n // 7. Trigger sync operations (fire-and-forget, non-blocking)\n if (!options.skipSync && !isInsert) {\n void this.triggerSync(changedFields);\n }\n\n return {\n success: true,\n document: this.model.data,\n isNew: isInsert,\n modifiedCount: isInsert\n ? undefined\n : (result as UpdateResult).modifiedCount,\n };\n }\n\n /**\n * Validate and cast model data using the schema.\n *\n * Updates the model's data in-place with validated/casted values.\n *\n * @param isInsert - Whether this is an insert operation\n * @param options - Save options\n * @throws {ValidationError} If validation fails\n * @private\n */\n private async validateAndCast(\n isInsert: boolean,\n options: WriterOptions,\n ): Promise<void> {\n // Emit validating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validating\", {\n isInsert,\n options,\n mode: isInsert ? \"insert\" : \"update\",\n });\n }\n\n // Skip validation if requested or no schema defined\n if (options.skipValidation || !this.schema) {\n return;\n }\n\n // Whitelist the framework-managed system columns so a model carrying them\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\n // `when(...)` adds each timestamp/soft-delete column only when configured\n // (truthy) — the lazy factory means a disabled column (`false`) never even\n // builds a bogus schema key.\n //\n // The whitelist must apply to BOTH insert and update: on insert a caller may\n // supply a backdated createdAt (e.g. data migrations / imports), and without\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\n // writer, while \"fail\" rejects the whole insert.\n const systemColumns = {\n id: v.scalar().optional(),\n _id: v.any().optional(),\n ...when(this.ctor.createdAtColumn, () => ({\n [this.ctor.createdAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.updatedAtColumn, () => ({\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\n })),\n ...when(this.ctor.deletedAtColumn, () => ({\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\n })),\n };\n\n // Clone full schema for insert, partial (dirty keys only) for updates.\n const validationSchema = isInsert\n ? this.schema.clone().extend(systemColumns)\n : this.schema.clone(Object.keys(this.model.data)).extend(systemColumns);\n\n // Apply strict mode\n if (this.strictMode === \"strip\") {\n validationSchema.stripUnknown();\n } else if (this.strictMode === \"fail\") {\n validationSchema.allowUnknown(false);\n } else if (this.strictMode === \"allow\") {\n validationSchema.allowUnknown(true);\n }\n\n // Run validation\n const result = await v.validate(validationSchema, this.model.data, {\n context: {\n model: this.model,\n },\n ...getSealConfig(),\n });\n\n if (!result.isValid) {\n console.trace(result.errors);\n\n const error = new DatabaseWriterValidationError(\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\n result.errors,\n );\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result, error });\n }\n throw error;\n }\n\n // Update model data with validated/casted data\n this.model.replaceData(result.data);\n\n // Emit validated event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"validated\", { result });\n }\n }\n\n /**\n * Perform an insert operation.\n *\n * @param options - Save options\n * @returns Insert result\n * @private\n */\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\n // Generate ID if needed (NoSQL only)\n await this.generateNextId();\n\n // Get data to insert (already validated and casted)\n const dataToInsert = this.model.data;\n\n // Add createdAt and updatedAt to the data (using resolved column names)\n // The column names are already resolved through the hierarchy:\n // Model static property > Database config > Driver defaults > undefined\n //\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\n // value (e.g. data migrations / imports) is honored instead of overwritten.\n // This mirrors the upsert path's guard. updatedAt is always stamped to\n // reflect the moment the record is persisted.\n const createdAtColumn = this.ctor.createdAtColumn;\n\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\n dataToInsert[createdAtColumn] = new Date();\n }\n\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n dataToInsert[updatedAtColumn] = new Date();\n }\n\n // Emit creating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"creating\");\n }\n\n // INSERT: use full validated data\n const result = await this.driver.insert(this.table, dataToInsert);\n\n // Merge returned data (e.g., generated _id, timestamps)\n // Note: We use merge here because the result might not include all fields\n // (e.g., our generated 'id' field), and we don't want to lose them\n this.model.merge(result.document as Record<string, unknown>);\n\n // Reset dirty tracker immediately after merge to prevent\n // database-generated fields (like _id) from being marked as dirty\n this.model.dirtyTracker.reset();\n\n return result;\n }\n\n /**\n * Perform an update operation.\n *\n * @param options - Save options\n * @returns Update result\n * @private\n */\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\n // Emit updating event\n if (!options.skipEvents) {\n await this.model.emitEvent(\"updating\");\n }\n\n // Update the updatedAt timestamp (using resolved column name)\n const updatedAtColumn = this.ctor.updatedAtColumn;\n if (updatedAtColumn) {\n this.model.set(updatedAtColumn, new Date());\n }\n\n if (options.replace) {\n const document = await this.driver.replace(\n this.table,\n {\n [this.primaryKey]: this.model.get(this.primaryKey),\n },\n this.model.data,\n );\n\n if (document) {\n this.model.replaceData(document as Record<string, unknown>);\n }\n\n return { modifiedCount: document ? 1 : 0 };\n }\n\n // Build operations from dirty tracker\n const operations = this.buildUpdateOperations();\n\n // Build filter using primary key\n const filter = { [this.primaryKey]: this.model.get(this.primaryKey) };\n\n // Execute update with operations\n return await this.driver.update(this.table, filter, operations);\n }\n\n /**\n * Generate ID for the model if auto-generation is enabled.\n *\n * @private\n */\n public async generateNextId(): Promise<void> {\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\n return;\n }\n\n const idGenerator = this.dataSource.idGenerator;\n if (!idGenerator) {\n return;\n }\n\n // Resolve ID generation options from model configuration\n const initialId = this.resolveInitialId();\n\n const incrementIdBy = this.resolveIncrementBy();\n\n const id = await idGenerator.generateNextId({\n table: this.table,\n initialId,\n incrementIdBy,\n });\n\n this.model.set(\"id\", id);\n }\n\n /**\n * Build update operations from the model's dirty tracker.\n *\n * Handles both modified fields ($set) and removed fields ($unset).\n *\n * @returns Update operations for the driver\n * @private\n *\n * @example\n * ```typescript\n * // Model with changes\n * user.set(\"name\", \"Alice\");\n * user.unset(\"tempField\");\n *\n * const operations = this.buildUpdateOperations();\n * // {\n * // $set: { name: \"Alice\" },\n * // $unset: { tempField: 1 }\n * // }\n * ```\n */\n private buildUpdateOperations(): UpdateOperations {\n const operations: UpdateOperations = {};\n\n // Get dirty columns (modified fields)\n const dirtyColumns = this.model.getDirtyColumns();\n\n if (dirtyColumns.length > 0) {\n operations.$set = {};\n for (const column of dirtyColumns) {\n const value = this.model.get(column);\n if (value === undefined) continue;\n\n operations.$set[column] = this.model.get(column);\n }\n }\n\n // Get removed columns\n const removedColumns = this.model.getRemovedColumns();\n if (removedColumns.length > 0) {\n operations.$unset = {};\n for (const column of removedColumns) {\n operations.$unset[column] = 1;\n }\n }\n\n return operations;\n }\n\n /**\n * Resolve the initial ID from model configuration.\n *\n * Priority:\n * 1. Model.initialId (explicit value)\n * 2. Model.randomInitialId (random or function)\n * 3. Default: 1\n *\n * @returns The initial ID value\n * @private\n */\n private resolveInitialId(): number {\n if (this.ctor.initialId) {\n return this.ctor.initialId;\n }\n\n if (this.ctor.randomInitialId) {\n return typeof this.ctor.randomInitialId === \"function\"\n ? this.ctor.randomInitialId()\n : this.randomInt(10000, 499999);\n }\n\n return 1; // Default initial ID\n }\n\n /**\n * Resolve the increment value from model configuration.\n *\n * Priority:\n * 1. Model.incrementIdBy (explicit value)\n * 2. Model.randomIncrement (random or function)\n * 3. Default: 1\n *\n * @returns The increment value\n * @private\n */\n private resolveIncrementBy(): number {\n if (this.ctor.incrementIdBy) {\n return this.ctor.incrementIdBy;\n }\n\n if (this.ctor.randomIncrement) {\n return typeof this.ctor.randomIncrement === \"function\"\n ? this.ctor.randomIncrement()\n : this.randomInt(1, 10);\n }\n\n return 1; // Default increment\n }\n\n /**\n * Generate a random integer between min and max (inclusive).\n *\n * @param min - Minimum value\n * @param max - Maximum value\n * @returns Random integer\n * @private\n */\n private randomInt(min: number, max: number): number {\n return Math.floor(Math.random() * (max - min + 1)) + min;\n }\n\n /**\n * Trigger sync operations after successful save.\n *\n * Emits a model.updated event that ModelSyncOperation listens to.\n * The sync is handled by registered sync operations, not directly here.\n *\n * @param changedFields - Fields that were changed (for filtering)\n * @private\n */\n private async triggerSync(changedFields: string[]): Promise<void> {\n // Emit model.updated event - ModelSyncOperation listens to these\n await events.triggerAll(\n getModelUpdatedEvent(this.ctor),\n this.model,\n changedFields,\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"database-writer.mjs","names":[],"sources":["../../../../../../../cascade/src/writer/database-writer.ts"],"sourcesContent":["import events from \"@mongez/events\";\r\nimport { when } from \"@mongez/reinforcements\";\r\nimport { getSealConfig, v, type ObjectValidator } from \"@warlock.js/seal\";\r\nimport type {\r\n DriverContract,\r\n InsertResult,\r\n UpdateOperations,\r\n UpdateResult,\r\n} from \"../contracts/database-driver.contract\";\r\nimport type {\r\n WriterContract,\r\n WriterOptions,\r\n WriterResult,\r\n} from \"../contracts/database-writer.contract\";\r\nimport { mergeDriverFields } from \"../model/methods/accessor-methods\";\r\nimport type { ChildModel, Model } from \"../model/model\";\r\nimport { getModelUpdatedEvent } from \"../sync/model-events\";\r\nimport type { StrictMode } from \"../types\";\r\nimport { DatabaseWriterValidationError } from \"../validation\";\r\nimport type { DataSource } from \"./../data-source/data-source\";\r\n\r\n/**\r\n * Database writer service that orchestrates model persistence.\r\n *\r\n * Handles the complete save pipeline:\r\n * 1. Check for changes (skip if no changes and not new)\r\n * 2. Emit `saving` event (for data enrichment)\r\n * 3. Emit `validating` event\r\n * 4. Validate and cast data via @warlock.js/seal schema\r\n * 5. Emit `validated` event\r\n * 6. Generate ID (for new NoSQL records)\r\n * 7. Emit `creating`/`updating` events\r\n * 8. Execute insert or update via driver\r\n * 9. Merge returned data into model\r\n * 10. Reset dirty tracker and update `isNew` flag\r\n * 11. Emit `saved` and `created`/`updated` events\r\n *\r\n * @example\r\n * ```typescript\r\n * const user = new User({ name: \"Alice\", email: \"alice@example.com\" });\r\n * const writer = new DatabaseWriter(user);\r\n * await writer.save();\r\n *\r\n * console.log(user.get(\"id\")); // 1 (auto-generated)\r\n * console.log(user.get(\"_id\")); // ObjectId(\"...\")\r\n *\r\n * // Update existing record\r\n * user.set(\"name\", \"Alice Smith\");\r\n * await writer.save();\r\n * // Only updates the \"name\" field (partial update)\r\n *\r\n * // Silent save (no events)\r\n * await writer.save({ skipEvents: true });\r\n * ```\r\n */\r\nexport class DatabaseWriter implements WriterContract {\r\n /** The model instance being persisted */\r\n private readonly model: Model;\r\n\r\n /** Model constructor reference */\r\n private readonly ctor: ChildModel<Model>;\r\n\r\n /** Data source containing driver and ID generator */\r\n private readonly dataSource: DataSource;\r\n\r\n /** Database driver for executing queries */\r\n private readonly driver: DriverContract;\r\n\r\n /** Table/collection name */\r\n private readonly table: string;\r\n\r\n /** Primary key field name */\r\n private readonly primaryKey: string;\r\n\r\n /** Validation schema (if defined) */\r\n private readonly schema?: ObjectValidator;\r\n\r\n /** Strict mode configuration */\r\n private readonly strictMode: StrictMode;\r\n\r\n /**\r\n * Create a new writer instance for a model.\r\n *\r\n * @param model - The model instance to persist\r\n *\r\n * @example\r\n * ```typescript\r\n * const user = new User({ name: \"Alice\" });\r\n * const writer = new DatabaseWriter(user);\r\n * await writer.save();\r\n * ```\r\n */\r\n public constructor(model: Model) {\r\n this.model = model;\r\n this.ctor = model.constructor as ChildModel<Model>;\r\n this.dataSource = this.ctor.getDataSource();\r\n this.driver = this.dataSource.driver;\r\n this.table = this.ctor.table;\r\n this.primaryKey = this.ctor.primaryKey;\r\n this.schema = this.ctor.schema;\r\n this.strictMode = this.ctor.strictMode;\r\n }\r\n\r\n /**\r\n * Save the model instance to the database.\r\n *\r\n * @param options - Save options\r\n * @returns Result with success status, document, and metadata\r\n * @throws {ValidationError} If validation fails\r\n */\r\n public async save(options: WriterOptions = {}): Promise<WriterResult> {\r\n const isInsert = this.model.isNew;\r\n\r\n // 1. Check if model has changes (skip if no changes and not new)\r\n if (!isInsert && !this.model.hasChanges()) {\r\n return {\r\n success: true,\r\n document: this.model.data,\r\n isNew: false,\r\n modifiedCount: 0,\r\n };\r\n }\r\n\r\n // 2. Emit saving event (before validation for data enrichment)\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"saving\", {\r\n isInsert,\r\n options,\r\n mode: isInsert ? \"insert\" : \"update\",\r\n });\r\n }\r\n\r\n // 3. Validate and cast data\r\n await this.validateAndCast(isInsert, options);\r\n\r\n // 4. Execute insert or update\r\n let result: InsertResult | UpdateResult;\r\n\r\n if (isInsert) {\r\n result = await this.performInsert(options);\r\n } else {\r\n result = await this.performUpdate(options);\r\n }\r\n\r\n // 5. Reset dirty tracker and update isNew flag\r\n const changedFields = isInsert ? [] : this.model.getDirtyColumns();\r\n this.model.dirtyTracker.reset();\r\n this.model.isNew = false;\r\n\r\n // 6. Emit post-save events\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"saved\");\r\n await this.model.emitEvent(isInsert ? \"created\" : \"updated\");\r\n }\r\n\r\n // 7. Trigger sync operations (fire-and-forget, non-blocking)\r\n if (!options.skipSync && !isInsert) {\r\n void this.triggerSync(changedFields);\r\n }\r\n\r\n return {\r\n success: true,\r\n document: this.model.data,\r\n isNew: isInsert,\r\n modifiedCount: isInsert\r\n ? undefined\r\n : (result as UpdateResult).modifiedCount,\r\n };\r\n }\r\n\r\n /**\r\n * Validate and cast model data using the schema.\r\n *\r\n * Updates the model's data in-place with validated/casted values.\r\n *\r\n * @param isInsert - Whether this is an insert operation\r\n * @param options - Save options\r\n * @throws {ValidationError} If validation fails\r\n * @private\r\n */\r\n private async validateAndCast(\r\n isInsert: boolean,\r\n options: WriterOptions,\r\n ): Promise<void> {\r\n // Emit validating event\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"validating\", {\r\n isInsert,\r\n options,\r\n mode: isInsert ? \"insert\" : \"update\",\r\n });\r\n }\r\n\r\n // Skip validation if requested or no schema defined\r\n if (options.skipValidation || !this.schema) {\r\n return;\r\n }\r\n\r\n // Whitelist the framework-managed system columns so a model carrying them\r\n // (id / _id / timestamps / soft-delete deletedAt) validates cleanly instead\r\n // of being stripped (strictMode \"strip\") or rejected (strictMode \"fail\").\r\n // `when(...)` adds each timestamp/soft-delete column only when configured\r\n // (truthy) — the lazy factory means a disabled column (`false`) never even\r\n // builds a bogus schema key.\r\n //\r\n // The whitelist must apply to BOTH insert and update: on insert a caller may\r\n // supply a backdated createdAt (e.g. data migrations / imports), and without\r\n // the whitelist strictMode \"strip\" silently drops it before it reaches the\r\n // writer, while \"fail\" rejects the whole insert.\r\n const systemColumns = {\r\n id: v.scalar().optional(),\r\n _id: v.any().optional(),\r\n ...when(this.ctor.createdAtColumn, () => ({\r\n [this.ctor.createdAtColumn as string]: v.date().optional(),\r\n })),\r\n ...when(this.ctor.updatedAtColumn, () => ({\r\n [this.ctor.updatedAtColumn as string]: v.date().optional(),\r\n })),\r\n ...when(this.ctor.deletedAtColumn, () => ({\r\n [this.ctor.deletedAtColumn as string]: v.date().optional(),\r\n })),\r\n };\r\n\r\n // Clone full schema for insert, partial (dirty keys only) for updates.\r\n const validationSchema = isInsert\r\n ? this.schema.clone().extend(systemColumns)\r\n : this.schema.clone(Object.keys(this.model.data)).extend(systemColumns);\r\n\r\n // Apply strict mode\r\n if (this.strictMode === \"strip\") {\r\n validationSchema.stripUnknown();\r\n } else if (this.strictMode === \"fail\") {\r\n validationSchema.allowUnknown(false);\r\n } else if (this.strictMode === \"allow\") {\r\n validationSchema.allowUnknown(true);\r\n }\r\n\r\n // Run validation\r\n const result = await v.validate(validationSchema, this.model.data, {\r\n context: {\r\n model: this.model,\r\n },\r\n ...getSealConfig(),\r\n });\r\n\r\n if (!result.isValid) {\r\n console.trace(result.errors);\r\n\r\n const error = new DatabaseWriterValidationError(\r\n `[${this.model.constructor.name} Model] ${isInsert ? \"Insert\" : \"Update\"} Validation failed`,\r\n result.errors,\r\n );\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"validated\", { result, error });\r\n }\r\n throw error;\r\n }\r\n\r\n // Update model data with validated/casted data\r\n this.model.replaceData(result.data);\r\n\r\n // Emit validated event\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"validated\", { result });\r\n }\r\n }\r\n\r\n /**\r\n * Perform an insert operation.\r\n *\r\n * @param options - Save options\r\n * @returns Insert result\r\n * @private\r\n */\r\n private async performInsert(options: WriterOptions): Promise<InsertResult> {\r\n // Generate ID if needed (NoSQL only)\r\n await this.generateNextId();\r\n\r\n // Get data to insert (already validated and casted)\r\n const dataToInsert = this.model.data;\r\n\r\n // Add createdAt and updatedAt to the data (using resolved column names)\r\n // The column names are already resolved through the hierarchy:\r\n // Model static property > Database config > Driver defaults > undefined\r\n //\r\n // Only stamp createdAt when the caller did NOT supply one, so a backdated\r\n // value (e.g. data migrations / imports) is honored instead of overwritten.\r\n // This mirrors the upsert path's guard. updatedAt is always stamped to\r\n // reflect the moment the record is persisted.\r\n const createdAtColumn = this.ctor.createdAtColumn;\r\n\r\n if (createdAtColumn && dataToInsert[createdAtColumn] == null) {\r\n dataToInsert[createdAtColumn] = new Date();\r\n }\r\n\r\n const updatedAtColumn = this.ctor.updatedAtColumn;\r\n if (updatedAtColumn) {\r\n dataToInsert[updatedAtColumn] = new Date();\r\n }\r\n\r\n // Emit creating event\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"creating\");\r\n }\r\n\r\n // INSERT: use full validated data\r\n const result = await this.driver.insert(this.table, dataToInsert);\r\n\r\n // Merge returned data (e.g., generated _id, timestamps)\r\n // Note: We use merge here because the result might not include all fields\r\n // (e.g., our generated 'id' field), and we don't want to lose them\r\n mergeDriverFields(this.model, result.document as Record<string, unknown>);\r\n\r\n // Reset dirty tracker immediately after merge to prevent\r\n // database-generated fields (like _id) from being marked as dirty\r\n this.model.dirtyTracker.reset();\r\n\r\n return result;\r\n }\r\n\r\n /**\r\n * Perform an update operation.\r\n *\r\n * @param options - Save options\r\n * @returns Update result\r\n * @private\r\n */\r\n private async performUpdate(options: WriterOptions): Promise<UpdateResult> {\r\n // Emit updating event\r\n if (!options.skipEvents) {\r\n await this.model.emitEvent(\"updating\");\r\n }\r\n\r\n // Update the updatedAt timestamp (using resolved column name)\r\n const updatedAtColumn = this.ctor.updatedAtColumn;\r\n if (updatedAtColumn) {\r\n this.model.set(updatedAtColumn, new Date());\r\n }\r\n\r\n if (options.replace) {\r\n const document = await this.driver.replace(\r\n this.table,\r\n this.buildPrimaryKeyFilter(),\r\n this.model.data,\r\n );\r\n\r\n if (document) {\r\n this.model.replaceData(document as Record<string, unknown>);\r\n }\r\n\r\n return { modifiedCount: document ? 1 : 0 };\r\n }\r\n\r\n // Build operations from dirty tracker\r\n const operations = this.buildUpdateOperations();\r\n\r\n // Nothing left to write once identity columns are excluded — don't hand the\r\n // driver an empty update document (MongoDB rejects one).\r\n if (Object.keys(operations).length === 0) {\r\n return { modifiedCount: 0 };\r\n }\r\n\r\n // Execute update with operations\r\n return await this.driver.update(\r\n this.table,\r\n this.buildPrimaryKeyFilter(),\r\n operations,\r\n );\r\n }\r\n\r\n /**\r\n * Build the filter that pins a write to the row this model was loaded from.\r\n *\r\n * It reads `model.trustedPrimaryKey` — the value captured when the instance\r\n * became persisted — NOT the current value in `model.data`. The current value\r\n * is reachable by mass assignment (`model.merge(req.body)`), so deriving the\r\n * filter from it let a request body redirect the UPDATE to another document.\r\n *\r\n * @returns Filter matching the originally loaded record\r\n * @private\r\n */\r\n private buildPrimaryKeyFilter(): Record<string, unknown> {\r\n return { [this.primaryKey]: this.model.trustedPrimaryKey };\r\n }\r\n\r\n /**\r\n * Generate ID for the model if auto-generation is enabled.\r\n *\r\n * @private\r\n */\r\n public async generateNextId(): Promise<void> {\r\n if (!this.ctor.autoGenerateId || this.model.get(\"id\")) {\r\n return;\r\n }\r\n\r\n const idGenerator = this.dataSource.idGenerator;\r\n if (!idGenerator) {\r\n return;\r\n }\r\n\r\n // Resolve ID generation options from model configuration\r\n const initialId = this.resolveInitialId();\r\n\r\n const incrementIdBy = this.resolveIncrementBy();\r\n\r\n const id = await idGenerator.generateNextId({\r\n table: this.table,\r\n initialId,\r\n incrementIdBy,\r\n });\r\n\r\n this.model.set(\"id\", id);\r\n }\r\n\r\n /**\r\n * Build update operations from the model's dirty tracker.\r\n *\r\n * Handles both modified fields ($set) and removed fields ($unset).\r\n *\r\n * @returns Update operations for the driver\r\n * @private\r\n *\r\n * @example\r\n * ```typescript\r\n * // Model with changes\r\n * user.set(\"name\", \"Alice\");\r\n * user.unset(\"tempField\");\r\n *\r\n * const operations = this.buildUpdateOperations();\r\n * // {\r\n * // $set: { name: \"Alice\" },\r\n * // $unset: { tempField: 1 }\r\n * // }\r\n * ```\r\n */\r\n private buildUpdateOperations(): UpdateOperations {\r\n const operations: UpdateOperations = {};\r\n\r\n // Identity columns are never written by an update. The filter pins the row\r\n // by its captured primary key, so a dirty `id`/`_id` can only come from\r\n // mass assignment or an explicit set() — either way, rewriting the key of\r\n // an existing row corrupts identity (and `_id` is immutable in MongoDB).\r\n // Changing a primary key is a deliberate operation: use the atomic/raw APIs.\r\n const identityColumns = new Set([this.primaryKey, \"id\", \"_id\"]);\r\n\r\n // Get dirty columns (modified fields)\r\n const dirtyColumns = this.model\r\n .getDirtyColumns()\r\n .filter(column => !identityColumns.has(column));\r\n\r\n if (dirtyColumns.length > 0) {\r\n operations.$set = {};\r\n for (const column of dirtyColumns) {\r\n const value = this.model.get(column);\r\n if (value === undefined) continue;\r\n\r\n operations.$set[column] = this.model.get(column);\r\n }\r\n }\r\n\r\n // Get removed columns\r\n const removedColumns = this.model\r\n .getRemovedColumns()\r\n .filter(column => !identityColumns.has(column));\r\n\r\n if (removedColumns.length > 0) {\r\n operations.$unset = {};\r\n for (const column of removedColumns) {\r\n operations.$unset[column] = 1;\r\n }\r\n }\r\n\r\n return operations;\r\n }\r\n\r\n /**\r\n * Resolve the initial ID from model configuration.\r\n *\r\n * Priority:\r\n * 1. Model.initialId (explicit value)\r\n * 2. Model.randomInitialId (random or function)\r\n * 3. Default: 1\r\n *\r\n * @returns The initial ID value\r\n * @private\r\n */\r\n private resolveInitialId(): number {\r\n if (this.ctor.initialId) {\r\n return this.ctor.initialId;\r\n }\r\n\r\n if (this.ctor.randomInitialId) {\r\n return typeof this.ctor.randomInitialId === \"function\"\r\n ? this.ctor.randomInitialId()\r\n : this.randomInt(10000, 499999);\r\n }\r\n\r\n return 1; // Default initial ID\r\n }\r\n\r\n /**\r\n * Resolve the increment value from model configuration.\r\n *\r\n * Priority:\r\n * 1. Model.incrementIdBy (explicit value)\r\n * 2. Model.randomIncrement (random or function)\r\n * 3. Default: 1\r\n *\r\n * @returns The increment value\r\n * @private\r\n */\r\n private resolveIncrementBy(): number {\r\n if (this.ctor.incrementIdBy) {\r\n return this.ctor.incrementIdBy;\r\n }\r\n\r\n if (this.ctor.randomIncrement) {\r\n return typeof this.ctor.randomIncrement === \"function\"\r\n ? this.ctor.randomIncrement()\r\n : this.randomInt(1, 10);\r\n }\r\n\r\n return 1; // Default increment\r\n }\r\n\r\n /**\r\n * Generate a random integer between min and max (inclusive).\r\n *\r\n * @param min - Minimum value\r\n * @param max - Maximum value\r\n * @returns Random integer\r\n * @private\r\n */\r\n private randomInt(min: number, max: number): number {\r\n return Math.floor(Math.random() * (max - min + 1)) + min;\r\n }\r\n\r\n /**\r\n * Trigger sync operations after successful save.\r\n *\r\n * Emits a model.updated event that ModelSyncOperation listens to.\r\n * The sync is handled by registered sync operations, not directly here.\r\n *\r\n * @param changedFields - Fields that were changed (for filtering)\r\n * @private\r\n */\r\n private async triggerSync(changedFields: string[]): Promise<void> {\r\n // Emit model.updated event - ModelSyncOperation listens to these\r\n await events.triggerAll(\r\n getModelUpdatedEvent(this.ctor),\r\n this.model,\r\n changedFields,\r\n );\r\n }\r\n}\r\n"],"mappings":"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"}
|