@classytic/repo-core 0.3.0 → 0.4.1
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 +243 -0
- package/dist/_virtual/_rolldown/runtime.mjs +7 -0
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/lock/index.d.mts +132 -0
- package/dist/lock/index.mjs +162 -0
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +936 -49
- package/dist/schema/field-rules.d.mts +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +3 -2
- package/dist/testing/index.mjs +2 -1
- package/dist/testing/lock-conformance.d.mts +25 -0
- package/dist/testing/lock-conformance.mjs +167 -0
- package/dist/testing/types.d.mts +99 -2
- package/package.json +23 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//#region src/plugins/tenant-helpers.ts
|
|
2
|
+
/**
|
|
3
|
+
* True when the op's policy target already has `tenantField` set by
|
|
4
|
+
* the caller. Used to decide whether the plugin can safely skip
|
|
5
|
+
* injecting a tenant scope rather than throwing on a missing context.
|
|
6
|
+
*
|
|
7
|
+
* - `data` — `context.data[tenantField]` is present
|
|
8
|
+
* - `dataArray` — every row in `context.dataArray` has `tenantField`
|
|
9
|
+
* - `query` — `context.query[tenantField]` is present
|
|
10
|
+
* - `filters` — `context.filters[tenantField]` is present
|
|
11
|
+
* - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
|
|
12
|
+
* - `none` — unreachable (the hook isn't registered for these ops)
|
|
13
|
+
*
|
|
14
|
+
* For multi-row targets (`dataArray`, `operations`) we require EVERY
|
|
15
|
+
* row to be stamped. Partial stamping is ambiguous (we have no
|
|
16
|
+
* resolver value to fill in the gaps) and is safer to treat as "not
|
|
17
|
+
* stamped" so the caller either stamps all rows or supplies a
|
|
18
|
+
* context/resolver.
|
|
19
|
+
*/
|
|
20
|
+
function payloadHasTenantField(context, policyKey, tenantField) {
|
|
21
|
+
switch (policyKey) {
|
|
22
|
+
case "data": return context.data?.[tenantField] != null;
|
|
23
|
+
case "dataArray": {
|
|
24
|
+
const arr = context.dataArray;
|
|
25
|
+
if (!Array.isArray(arr) || arr.length === 0) return false;
|
|
26
|
+
return arr.every((row) => row && row[tenantField] != null);
|
|
27
|
+
}
|
|
28
|
+
case "query": return context.query?.[tenantField] != null;
|
|
29
|
+
case "filters": return context.filters?.[tenantField] != null;
|
|
30
|
+
case "operations": {
|
|
31
|
+
const ops = context.operations;
|
|
32
|
+
if (!Array.isArray(ops) || ops.length === 0) return false;
|
|
33
|
+
return ops.every((subOp) => {
|
|
34
|
+
for (const key of [
|
|
35
|
+
"updateOne",
|
|
36
|
+
"updateMany",
|
|
37
|
+
"deleteOne",
|
|
38
|
+
"deleteMany",
|
|
39
|
+
"replaceOne"
|
|
40
|
+
]) {
|
|
41
|
+
const body = subOp[key];
|
|
42
|
+
if (body) return body["filter"]?.[tenantField] != null;
|
|
43
|
+
}
|
|
44
|
+
const ins = subOp["insertOne"];
|
|
45
|
+
if (ins) return ins["document"]?.[tenantField] != null;
|
|
46
|
+
return false;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
default: return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build a `skipWhen`-compatible callback that bypasses tenant scoping
|
|
54
|
+
* when the caller's role is in `adminRoles`. Composable with any
|
|
55
|
+
* kit's multi-tenant plugin shape.
|
|
56
|
+
*
|
|
57
|
+
* The factory does an exact-match `Set.has` check — case-sensitive,
|
|
58
|
+
* no fuzzy matching. Lowercase your role vocabulary upstream.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* multiTenantPlugin({
|
|
63
|
+
* resolveTenantId: ctx => ctx.organizationId,
|
|
64
|
+
* skipWhen: adminBypass({ adminRoles: ['superadmin', 'support'] }),
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @param options.roleField Context key holding the role string (default: `'role'`)
|
|
69
|
+
* @param options.adminRoles Roles that bypass tenant scope. Frozen on
|
|
70
|
+
* factory construction so callers can't mutate the list afterward
|
|
71
|
+
* and silently change bypass semantics across plugin instances
|
|
72
|
+
* sharing the array reference.
|
|
73
|
+
* @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
|
|
74
|
+
*/
|
|
75
|
+
function adminBypass(options) {
|
|
76
|
+
const { roleField = "role", adminRoles } = options;
|
|
77
|
+
const allowed = new Set(adminRoles);
|
|
78
|
+
return function skipWhenAdmin(context) {
|
|
79
|
+
const role = context[roleField];
|
|
80
|
+
return typeof role === "string" && allowed.has(role);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { adminBypass, payloadHasTenantField };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BracketOperator, ParsedPopulate, ParsedQuery, ParsedSelect, ParsedSort, ParsedSortDirection, QueryParserInput, QueryParserOptions } from "./types.mjs";
|
|
2
2
|
import { coerceList, coerceValue } from "./coerce.mjs";
|
|
3
3
|
import { parseUrl } from "./parse-url.mjs";
|
|
4
|
-
|
|
4
|
+
import { STANDARD_RESERVED_PARAMS, isControlParam } from "./reserved.mjs";
|
|
5
|
+
export { type BracketOperator, type ParsedPopulate, type ParsedQuery, type ParsedSelect, type ParsedSort, type ParsedSortDirection, type QueryParserInput, type QueryParserOptions, STANDARD_RESERVED_PARAMS, coerceList, coerceValue, isControlParam, parseUrl };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { coerceList, coerceValue } from "./coerce.mjs";
|
|
2
|
+
import { STANDARD_RESERVED_PARAMS, isControlParam } from "./reserved.mjs";
|
|
2
3
|
import { parseUrl } from "./parse-url.mjs";
|
|
3
|
-
export { coerceList, coerceValue, parseUrl };
|
|
4
|
+
export { STANDARD_RESERVED_PARAMS, coerceList, coerceValue, isControlParam, parseUrl };
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { TRUE, and, between, contains, endsWith, eq, gt, gte, iEq, in_, isNotNull, isNull, like, lt, lte, ne, nin, regex, startsWith } from "../filter/builders.mjs";
|
|
2
2
|
import { coerceList, coerceValue } from "./coerce.mjs";
|
|
3
|
+
import { isControlParam } from "./reserved.mjs";
|
|
3
4
|
//#region src/query-parser/parse-url.ts
|
|
4
5
|
const DEFAULT_LIMIT = 20;
|
|
5
6
|
const DEFAULT_MAX_LIMIT = 200;
|
|
6
7
|
const DEFAULT_MAX_DEPTH = 10;
|
|
7
8
|
const DEFAULT_MAX_REGEX = 500;
|
|
8
9
|
const DEFAULT_MAX_SEARCH = 200;
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
]);
|
|
10
|
+
/**
|
|
11
|
+
* Hard cap on URL parameter KEY length. Param keys land in regex-based
|
|
12
|
+
* bracket parsing (`/^([^[\]]+)\[([^\]]+)\]$/` and friends); without a
|
|
13
|
+
* length bound, a hostile caller can submit a 1MB key and force the
|
|
14
|
+
* parser to scan the entire string for every regex try. Keys that
|
|
15
|
+
* exceed this cap are silently skipped — legitimate URL params don't
|
|
16
|
+
* approach this bound.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_PARAM_KEY_LENGTH = 256;
|
|
19
19
|
const ALL_OPERATORS = new Set([
|
|
20
20
|
"eq",
|
|
21
21
|
"ne",
|
|
@@ -134,6 +134,7 @@ function parseSelect(raw) {
|
|
|
134
134
|
function parsePopulate(params) {
|
|
135
135
|
const byField = /* @__PURE__ */ new Map();
|
|
136
136
|
for (const [key, value] of params.entries()) {
|
|
137
|
+
if (key.length > MAX_PARAM_KEY_LENGTH) continue;
|
|
137
138
|
if (!key.startsWith("populate[")) continue;
|
|
138
139
|
const match = /^populate\[([^\]]+)\](?:\[([^\]]+)\](?:\[([^\]]+)\])?)?$/.exec(key);
|
|
139
140
|
if (!match) continue;
|
|
@@ -163,7 +164,8 @@ function parseFilters(params, ctx) {
|
|
|
163
164
|
const leaves = [];
|
|
164
165
|
const fieldGroups = /* @__PURE__ */ new Map();
|
|
165
166
|
for (const [key, rawValue] of params.entries()) {
|
|
166
|
-
if (
|
|
167
|
+
if (key.length > MAX_PARAM_KEY_LENGTH) continue;
|
|
168
|
+
if (isControlParam(key) || key.startsWith("populate[")) continue;
|
|
167
169
|
const bracket = /^([^[\]]+)\[([^\]]+)\]$/.exec(key);
|
|
168
170
|
let field;
|
|
169
171
|
let op;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/query-parser/reserved.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* URL parameter keys reserved by the framework — never parsed as
|
|
4
|
+
* filter predicates. Two categories:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Pagination / list-control params** — `page`, `limit`, `after`,
|
|
7
|
+
* `sort`, `select`, `populate`, `search`. Universal across kits;
|
|
8
|
+
* every backend agrees on their meaning.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Resource-dispatch verbs** — `_count`, `_distinct`, `_exists`.
|
|
11
|
+
* Arc-style frameworks pick the repo method from these URL keys
|
|
12
|
+
* (list vs count vs distinct vs exists).
|
|
13
|
+
*
|
|
14
|
+
* **Why an explicit allowlist, not a `_*` namespace.** MongoDB's `_id`
|
|
15
|
+
* (and every kit's analog) is a legitimate filter field; user-defined
|
|
16
|
+
* `_internal`, `_meta`, `_v` fields are common in real schemas. A
|
|
17
|
+
* blanket `key.startsWith('_')` rule silently drops filters on these.
|
|
18
|
+
* Adding a new dispatch verb here is a deliberate ecosystem-wide
|
|
19
|
+
* change — small, audited, and only needed every few major versions.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Reserved top-level URL keys handled outside the filter pipeline.
|
|
23
|
+
* Pagination + sort + select + populate + search + resource-dispatch
|
|
24
|
+
* verbs (`_count`, `_distinct`, `_exists`).
|
|
25
|
+
*/
|
|
26
|
+
declare const STANDARD_RESERVED_PARAMS: ReadonlySet<string>;
|
|
27
|
+
/**
|
|
28
|
+
* True when `key` is a framework-reserved URL parameter and should be
|
|
29
|
+
* skipped during filter parsing.
|
|
30
|
+
*
|
|
31
|
+
* Use in any URL-parser implementation that decides "is this a filter
|
|
32
|
+
* predicate or a control flag?":
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* for (const [key, value] of params.entries()) {
|
|
36
|
+
* if (isControlParam(key)) continue; // skip page, limit, _count, ...
|
|
37
|
+
* // … parse as filter predicate
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
declare function isControlParam(key: string): boolean;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { STANDARD_RESERVED_PARAMS, isControlParam };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//#region src/query-parser/reserved.ts
|
|
2
|
+
/**
|
|
3
|
+
* URL parameter keys reserved by the framework — never parsed as
|
|
4
|
+
* filter predicates. Two categories:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Pagination / list-control params** — `page`, `limit`, `after`,
|
|
7
|
+
* `sort`, `select`, `populate`, `search`. Universal across kits;
|
|
8
|
+
* every backend agrees on their meaning.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Resource-dispatch verbs** — `_count`, `_distinct`, `_exists`.
|
|
11
|
+
* Arc-style frameworks pick the repo method from these URL keys
|
|
12
|
+
* (list vs count vs distinct vs exists).
|
|
13
|
+
*
|
|
14
|
+
* **Why an explicit allowlist, not a `_*` namespace.** MongoDB's `_id`
|
|
15
|
+
* (and every kit's analog) is a legitimate filter field; user-defined
|
|
16
|
+
* `_internal`, `_meta`, `_v` fields are common in real schemas. A
|
|
17
|
+
* blanket `key.startsWith('_')` rule silently drops filters on these.
|
|
18
|
+
* Adding a new dispatch verb here is a deliberate ecosystem-wide
|
|
19
|
+
* change — small, audited, and only needed every few major versions.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Reserved top-level URL keys handled outside the filter pipeline.
|
|
23
|
+
* Pagination + sort + select + populate + search + resource-dispatch
|
|
24
|
+
* verbs (`_count`, `_distinct`, `_exists`).
|
|
25
|
+
*/
|
|
26
|
+
const STANDARD_RESERVED_PARAMS = new Set([
|
|
27
|
+
"page",
|
|
28
|
+
"limit",
|
|
29
|
+
"after",
|
|
30
|
+
"sort",
|
|
31
|
+
"select",
|
|
32
|
+
"populate",
|
|
33
|
+
"search",
|
|
34
|
+
"_count",
|
|
35
|
+
"_distinct",
|
|
36
|
+
"_exists"
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* True when `key` is a framework-reserved URL parameter and should be
|
|
40
|
+
* skipped during filter parsing.
|
|
41
|
+
*
|
|
42
|
+
* Use in any URL-parser implementation that decides "is this a filter
|
|
43
|
+
* predicate or a control flag?":
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* for (const [key, value] of params.entries()) {
|
|
47
|
+
* if (isControlParam(key)) continue; // skip page, limit, _count, ...
|
|
48
|
+
* // … parse as filter predicate
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
function isControlParam(key) {
|
|
53
|
+
return STANDARD_RESERVED_PARAMS.has(key);
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { STANDARD_RESERVED_PARAMS, isControlParam };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/repository/agg-output.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cross-kit AggResult row-shape normalization.
|
|
4
|
+
*
|
|
5
|
+
* When an `AggRequest` includes `lookups` and `groupBy` references a
|
|
6
|
+
* joined-alias path (e.g. `'department.code'`), the row that lands in
|
|
7
|
+
* `AggResult.rows` carries the joined data as a NESTED object:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* { status: 'pending', department: { code: 'ENG' }, count: 3 }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Same convention `lookupPopulate` uses. Mongokit's `$project` with
|
|
14
|
+
* dotted-key output naturally nests; sqlitekit gets flat-dotted keys
|
|
15
|
+
* from Drizzle's SELECT alias map and runs results through
|
|
16
|
+
* `nestDottedKeys` before returning.
|
|
17
|
+
*
|
|
18
|
+
* **Why nested over flat-dotted?**
|
|
19
|
+
* - Matches `lookupPopulate` precedent (single convention across
|
|
20
|
+
* all read primitives).
|
|
21
|
+
* - JSON-clean: `{ department: { code: 'ENG' } }` round-trips
|
|
22
|
+
* identically through `JSON.stringify` / `parse`.
|
|
23
|
+
* - Cleaner consumer code: `row.department.code` vs
|
|
24
|
+
* `row['department.code']`.
|
|
25
|
+
* - BSON allows nested but disallows literal `.` in field names —
|
|
26
|
+
* the only shape that works in mongo without BSON workarounds.
|
|
27
|
+
*
|
|
28
|
+
* **Out of scope**:
|
|
29
|
+
* - Multi-level dotted paths (`'a.b.c'`) — kits don't emit these
|
|
30
|
+
* today (single-level joins only). The helper handles them
|
|
31
|
+
* correctly by recursive descent so future depth is supported.
|
|
32
|
+
* - Conflicting flat + nested keys on the same row (e.g. both
|
|
33
|
+
* `department` and `department.code`). The flat-dotted side wins;
|
|
34
|
+
* a top-level `department` value gets overwritten when a
|
|
35
|
+
* `department.<x>` partner key is processed. In practice this
|
|
36
|
+
* never happens — kits emit one or the other per groupBy key.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Walk a row's top-level keys, splitting any that contain `.` into
|
|
40
|
+
* nested objects. Keys without `.` pass through unchanged. Mutates a
|
|
41
|
+
* fresh output object — the input is not modified.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* nestDottedKeys({ status: 'pending', 'department.code': 'ENG', count: 3 })
|
|
46
|
+
* // → { status: 'pending', department: { code: 'ENG' }, count: 3 }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Multi-level paths (`a.b.c`) recurse:
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* nestDottedKeys({ 'a.b.c': 1 })
|
|
53
|
+
* // → { a: { b: { c: 1 } } }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare function nestDottedKeys<T extends Record<string, unknown>>(row: T): Record<string, unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* Convenience wrapper for an array of rows. Returns a new array of
|
|
59
|
+
* normalized rows; the input is not modified.
|
|
60
|
+
*/
|
|
61
|
+
declare function nestDottedKeysAll<T extends Record<string, unknown>>(rows: readonly T[]): Record<string, unknown>[];
|
|
62
|
+
//#endregion
|
|
63
|
+
export { nestDottedKeys, nestDottedKeysAll };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//#region src/repository/agg-output.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cross-kit AggResult row-shape normalization.
|
|
4
|
+
*
|
|
5
|
+
* When an `AggRequest` includes `lookups` and `groupBy` references a
|
|
6
|
+
* joined-alias path (e.g. `'department.code'`), the row that lands in
|
|
7
|
+
* `AggResult.rows` carries the joined data as a NESTED object:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* { status: 'pending', department: { code: 'ENG' }, count: 3 }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Same convention `lookupPopulate` uses. Mongokit's `$project` with
|
|
14
|
+
* dotted-key output naturally nests; sqlitekit gets flat-dotted keys
|
|
15
|
+
* from Drizzle's SELECT alias map and runs results through
|
|
16
|
+
* `nestDottedKeys` before returning.
|
|
17
|
+
*
|
|
18
|
+
* **Why nested over flat-dotted?**
|
|
19
|
+
* - Matches `lookupPopulate` precedent (single convention across
|
|
20
|
+
* all read primitives).
|
|
21
|
+
* - JSON-clean: `{ department: { code: 'ENG' } }` round-trips
|
|
22
|
+
* identically through `JSON.stringify` / `parse`.
|
|
23
|
+
* - Cleaner consumer code: `row.department.code` vs
|
|
24
|
+
* `row['department.code']`.
|
|
25
|
+
* - BSON allows nested but disallows literal `.` in field names —
|
|
26
|
+
* the only shape that works in mongo without BSON workarounds.
|
|
27
|
+
*
|
|
28
|
+
* **Out of scope**:
|
|
29
|
+
* - Multi-level dotted paths (`'a.b.c'`) — kits don't emit these
|
|
30
|
+
* today (single-level joins only). The helper handles them
|
|
31
|
+
* correctly by recursive descent so future depth is supported.
|
|
32
|
+
* - Conflicting flat + nested keys on the same row (e.g. both
|
|
33
|
+
* `department` and `department.code`). The flat-dotted side wins;
|
|
34
|
+
* a top-level `department` value gets overwritten when a
|
|
35
|
+
* `department.<x>` partner key is processed. In practice this
|
|
36
|
+
* never happens — kits emit one or the other per groupBy key.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Walk a row's top-level keys, splitting any that contain `.` into
|
|
40
|
+
* nested objects. Keys without `.` pass through unchanged. Mutates a
|
|
41
|
+
* fresh output object — the input is not modified.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* nestDottedKeys({ status: 'pending', 'department.code': 'ENG', count: 3 })
|
|
46
|
+
* // → { status: 'pending', department: { code: 'ENG' }, count: 3 }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Multi-level paths (`a.b.c`) recurse:
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* nestDottedKeys({ 'a.b.c': 1 })
|
|
53
|
+
* // → { a: { b: { c: 1 } } }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
function nestDottedKeys(row) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [key, value] of Object.entries(row)) {
|
|
59
|
+
if (key.indexOf(".") < 0) {
|
|
60
|
+
out[key] = value;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
setDeep(out, key.split("."), value);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Convenience wrapper for an array of rows. Returns a new array of
|
|
69
|
+
* normalized rows; the input is not modified.
|
|
70
|
+
*/
|
|
71
|
+
function nestDottedKeysAll(rows) {
|
|
72
|
+
return rows.map((r) => nestDottedKeys(r));
|
|
73
|
+
}
|
|
74
|
+
function setDeep(target, path, value) {
|
|
75
|
+
let cursor = target;
|
|
76
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
77
|
+
const segment = path[i];
|
|
78
|
+
const existing = cursor[segment];
|
|
79
|
+
if (existing && typeof existing === "object" && !Array.isArray(existing)) cursor = existing;
|
|
80
|
+
else {
|
|
81
|
+
const next = {};
|
|
82
|
+
cursor[segment] = next;
|
|
83
|
+
cursor = next;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cursor[path[path.length - 1]] = value;
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
export { nestDottedKeys, nestDottedKeysAll };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec } from "../lookup/types.mjs";
|
|
2
2
|
import { UpdateInput } from "../update/types.mjs";
|
|
3
|
+
import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
|
|
3
4
|
import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
|
|
4
5
|
import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
|
|
5
|
-
import {
|
|
6
|
-
|
|
6
|
+
import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
|
|
7
|
+
import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
|
|
8
|
+
export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type UpdateInput, type UpdateManyResult, type WriteOptions, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
|
|
1
2
|
import { PLUGIN_ORDER_CONSTRAINTS, validatePluginOrder } from "./plugin-types.mjs";
|
|
2
3
|
import { RepositoryBase } from "./base.mjs";
|
|
3
|
-
|
|
4
|
+
import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
|
|
5
|
+
export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/repository/options.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical option keys forwarded into every `MinimalRepo` /
|
|
4
|
+
* `StandardRepo` method call.
|
|
5
|
+
*
|
|
6
|
+
* The options bag is the cross-cutting plumbing every kit's plugin
|
|
7
|
+
* layer reads from: multi-tenant scope, audit attribution, transaction
|
|
8
|
+
* threading, observability correlation. Hosts (and arc-style
|
|
9
|
+
* frameworks) extract these from the request context once and forward
|
|
10
|
+
* them into every repo call so plugins don't need request-context
|
|
11
|
+
* access of their own.
|
|
12
|
+
*
|
|
13
|
+
* Without a single agreed-on set, drift is inevitable: one host
|
|
14
|
+
* forwards `userId`, another forwards `actorId`, a third forgets
|
|
15
|
+
* `requestId` entirely — and audit logs lose attribution silently.
|
|
16
|
+
* `STANDARD_REPO_OPTION_KEYS` is the contract every kit and every
|
|
17
|
+
* arc-style framework agrees on. Adding a key here is a deliberate
|
|
18
|
+
* ecosystem-wide commitment.
|
|
19
|
+
*
|
|
20
|
+
* Kits implementing custom plugins (commission, supplier-performance,
|
|
21
|
+
* pos, ...) can declare their own canonical sets via mongokit's
|
|
22
|
+
* `createOptionsExtractor<TCtx>` — that pattern stays domain-local and
|
|
23
|
+
* doesn't pollute the cross-kit contract.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The canonical keys every kit's plugin layer reads from the options
|
|
27
|
+
* bag, and every framework auto-threads from request context.
|
|
28
|
+
*
|
|
29
|
+
* - `organizationId` — multi-tenant scope. Tenant plugins
|
|
30
|
+
* (mongokit's `multiTenantPlugin`, sqlitekit's tenant filter) read
|
|
31
|
+
* it to stamp on write + filter on read. Cast handling (e.g.
|
|
32
|
+
* `ObjectId` coercion) is plugin-local — pass the raw scope id.
|
|
33
|
+
* - `userId` — actor id for audit attribution. Audit-log / audit-
|
|
34
|
+
* trail plugins read it for the `who` column.
|
|
35
|
+
* - `user` — denormalized actor object, when the audit log wants
|
|
36
|
+
* richer payload than a bare id (display name, role snapshot, ...).
|
|
37
|
+
* - `session` — driver-specific transaction handle. Mongoose
|
|
38
|
+
* `ClientSession`, better-sqlite3 transaction fn, Prisma
|
|
39
|
+
* transaction client. Opaque to repo-core — kits narrow at the
|
|
40
|
+
* boundary.
|
|
41
|
+
* - `requestId` — request correlation id for trace stitching across
|
|
42
|
+
* logs, events, and downstream service calls.
|
|
43
|
+
*
|
|
44
|
+
* Frameworks should treat this set as the canonical forward list:
|
|
45
|
+
* peel matching keys off the request context, drop them into the
|
|
46
|
+
* options bag, and let kit plugins read what they implement. Unknown
|
|
47
|
+
* ctx keys do NOT forward — the bag stays narrow.
|
|
48
|
+
*/
|
|
49
|
+
declare const STANDARD_REPO_OPTION_KEYS: readonly ["organizationId", "userId", "user", "session", "requestId"];
|
|
50
|
+
/**
|
|
51
|
+
* Type-level union of canonical option keys. Use to constrain
|
|
52
|
+
* framework helpers that thread request context into repo options:
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* function pickStandardOptions(ctx: Record<string, unknown>): Partial<
|
|
56
|
+
* Record<StandardRepoOptionKey, unknown>
|
|
57
|
+
* > { ... }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
type StandardRepoOptionKey = (typeof STANDARD_REPO_OPTION_KEYS)[number];
|
|
61
|
+
//#endregion
|
|
62
|
+
export { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/repository/options.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical option keys forwarded into every `MinimalRepo` /
|
|
4
|
+
* `StandardRepo` method call.
|
|
5
|
+
*
|
|
6
|
+
* The options bag is the cross-cutting plumbing every kit's plugin
|
|
7
|
+
* layer reads from: multi-tenant scope, audit attribution, transaction
|
|
8
|
+
* threading, observability correlation. Hosts (and arc-style
|
|
9
|
+
* frameworks) extract these from the request context once and forward
|
|
10
|
+
* them into every repo call so plugins don't need request-context
|
|
11
|
+
* access of their own.
|
|
12
|
+
*
|
|
13
|
+
* Without a single agreed-on set, drift is inevitable: one host
|
|
14
|
+
* forwards `userId`, another forwards `actorId`, a third forgets
|
|
15
|
+
* `requestId` entirely — and audit logs lose attribution silently.
|
|
16
|
+
* `STANDARD_REPO_OPTION_KEYS` is the contract every kit and every
|
|
17
|
+
* arc-style framework agrees on. Adding a key here is a deliberate
|
|
18
|
+
* ecosystem-wide commitment.
|
|
19
|
+
*
|
|
20
|
+
* Kits implementing custom plugins (commission, supplier-performance,
|
|
21
|
+
* pos, ...) can declare their own canonical sets via mongokit's
|
|
22
|
+
* `createOptionsExtractor<TCtx>` — that pattern stays domain-local and
|
|
23
|
+
* doesn't pollute the cross-kit contract.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The canonical keys every kit's plugin layer reads from the options
|
|
27
|
+
* bag, and every framework auto-threads from request context.
|
|
28
|
+
*
|
|
29
|
+
* - `organizationId` — multi-tenant scope. Tenant plugins
|
|
30
|
+
* (mongokit's `multiTenantPlugin`, sqlitekit's tenant filter) read
|
|
31
|
+
* it to stamp on write + filter on read. Cast handling (e.g.
|
|
32
|
+
* `ObjectId` coercion) is plugin-local — pass the raw scope id.
|
|
33
|
+
* - `userId` — actor id for audit attribution. Audit-log / audit-
|
|
34
|
+
* trail plugins read it for the `who` column.
|
|
35
|
+
* - `user` — denormalized actor object, when the audit log wants
|
|
36
|
+
* richer payload than a bare id (display name, role snapshot, ...).
|
|
37
|
+
* - `session` — driver-specific transaction handle. Mongoose
|
|
38
|
+
* `ClientSession`, better-sqlite3 transaction fn, Prisma
|
|
39
|
+
* transaction client. Opaque to repo-core — kits narrow at the
|
|
40
|
+
* boundary.
|
|
41
|
+
* - `requestId` — request correlation id for trace stitching across
|
|
42
|
+
* logs, events, and downstream service calls.
|
|
43
|
+
*
|
|
44
|
+
* Frameworks should treat this set as the canonical forward list:
|
|
45
|
+
* peel matching keys off the request context, drop them into the
|
|
46
|
+
* options bag, and let kit plugins read what they implement. Unknown
|
|
47
|
+
* ctx keys do NOT forward — the bag stays narrow.
|
|
48
|
+
*/
|
|
49
|
+
const STANDARD_REPO_OPTION_KEYS = [
|
|
50
|
+
"organizationId",
|
|
51
|
+
"userId",
|
|
52
|
+
"user",
|
|
53
|
+
"session",
|
|
54
|
+
"requestId"
|
|
55
|
+
];
|
|
56
|
+
//#endregion
|
|
57
|
+
export { STANDARD_REPO_OPTION_KEYS };
|