@classytic/repo-core 0.1.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 +67 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/cache/index.d.mts +4 -0
- package/dist/cache/index.mjs +3 -0
- package/dist/cache/memory-adapter.d.mts +7 -0
- package/dist/cache/memory-adapter.mjs +37 -0
- package/dist/cache/stable-stringify.d.mts +15 -0
- package/dist/cache/stable-stringify.mjs +19 -0
- package/dist/cache/types.d.mts +59 -0
- package/dist/context/index.d.mts +2 -0
- package/dist/context/index.mjs +0 -0
- package/dist/context/types.d.mts +24 -0
- package/dist/errors/create-error.d.mts +19 -0
- package/dist/errors/create-error.mjs +23 -0
- package/dist/errors/duplicate-key.d.mts +38 -0
- package/dist/errors/duplicate-key.mjs +57 -0
- package/dist/errors/index.d.mts +4 -0
- package/dist/errors/index.mjs +3 -0
- package/dist/errors/types.d.mts +37 -0
- package/dist/filter/builders.d.mts +60 -0
- package/dist/filter/builders.mjs +172 -0
- package/dist/filter/guard.d.mts +13 -0
- package/dist/filter/guard.mjs +34 -0
- package/dist/filter/index.d.mts +7 -0
- package/dist/filter/index.mjs +6 -0
- package/dist/filter/match.d.mts +12 -0
- package/dist/filter/match.mjs +91 -0
- package/dist/filter/scope.d.mts +31 -0
- package/dist/filter/scope.mjs +54 -0
- package/dist/filter/types.d.mts +143 -0
- package/dist/filter/walk.d.mts +24 -0
- package/dist/filter/walk.mjs +77 -0
- package/dist/hooks/engine.d.mts +48 -0
- package/dist/hooks/engine.mjs +101 -0
- package/dist/hooks/events.d.mts +95 -0
- package/dist/hooks/events.mjs +93 -0
- package/dist/hooks/index.d.mts +5 -0
- package/dist/hooks/index.mjs +4 -0
- package/dist/hooks/priority.d.mts +23 -0
- package/dist/hooks/priority.mjs +21 -0
- package/dist/hooks/types.d.mts +37 -0
- package/dist/lookup/index.d.mts +2 -0
- package/dist/lookup/index.mjs +0 -0
- package/dist/lookup/types.d.mts +170 -0
- package/dist/operations/index.d.mts +3 -0
- package/dist/operations/index.mjs +2 -0
- package/dist/operations/registry.d.mts +41 -0
- package/dist/operations/registry.mjs +140 -0
- package/dist/operations/types.d.mts +49 -0
- package/dist/pagination/cursor.d.mts +44 -0
- package/dist/pagination/cursor.mjs +150 -0
- package/dist/pagination/index.d.mts +5 -0
- package/dist/pagination/index.mjs +4 -0
- package/dist/pagination/keyset.d.mts +25 -0
- package/dist/pagination/keyset.mjs +61 -0
- package/dist/pagination/offset.d.mts +26 -0
- package/dist/pagination/offset.mjs +47 -0
- package/dist/pagination/types.d.mts +136 -0
- package/dist/query-parser/coerce.d.mts +16 -0
- package/dist/query-parser/coerce.mjs +73 -0
- package/dist/query-parser/index.d.mts +4 -0
- package/dist/query-parser/index.mjs +3 -0
- package/dist/query-parser/parse-url.d.mts +7 -0
- package/dist/query-parser/parse-url.mjs +224 -0
- package/dist/query-parser/types.d.mts +104 -0
- package/dist/repository/base.d.mts +90 -0
- package/dist/repository/base.mjs +111 -0
- package/dist/repository/index.d.mts +5 -0
- package/dist/repository/index.mjs +3 -0
- package/dist/repository/plugin-types.d.mts +27 -0
- package/dist/repository/plugin-types.mjs +45 -0
- package/dist/repository/types.d.mts +470 -0
- package/dist/schema/field-rules.d.mts +62 -0
- package/dist/schema/field-rules.mjs +110 -0
- package/dist/schema/index.d.mts +3 -0
- package/dist/schema/index.mjs +2 -0
- package/dist/schema/types.d.mts +138 -0
- package/dist/testing/conformance.d.mts +6 -0
- package/dist/testing/conformance.mjs +481 -0
- package/dist/testing/index.d.mts +3 -0
- package/dist/testing/index.mjs +2 -0
- package/dist/testing/types.d.mts +113 -0
- package/package.json +130 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Filter } from "../filter/types.mjs";
|
|
2
|
+
import { KeysetPaginationResultCore, OffsetPaginationResultCore } from "../pagination/types.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/lookup/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Single lookup join. Reads like a `LEFT JOIN from ON from.foreignField
|
|
7
|
+
* = this.localField`, with the joined payload landing on `as`.
|
|
8
|
+
*
|
|
9
|
+
* Kit semantics (identical output shape across all three):
|
|
10
|
+
*
|
|
11
|
+
* - **mongokit** → `{ $lookup: { from, localField, foreignField, as,
|
|
12
|
+
* pipeline?: [{ $project: select }] } }`, optionally followed by
|
|
13
|
+
* `$unwind` when `single` is true.
|
|
14
|
+
* - **sqlitekit** → `LEFT JOIN "${from}" ON "${from}"."${foreignField}"
|
|
15
|
+
* = base."${localField}"` with a projected `json_object(...)` or
|
|
16
|
+
* `json_group_array(json_object(...))` column aliased to `as`.
|
|
17
|
+
* - **pgkit** (future) → `LEFT JOIN LATERAL (SELECT ... WHERE ...)`
|
|
18
|
+
* with `row_to_json` / `json_agg`.
|
|
19
|
+
*
|
|
20
|
+
* The `from` value is a string table/collection name — kits resolve it
|
|
21
|
+
* via their registry (Drizzle schema for SQL, mongoose connection for
|
|
22
|
+
* mongokit). Typos fail at query-time with a clear error, not silently.
|
|
23
|
+
*/
|
|
24
|
+
interface LookupSpec {
|
|
25
|
+
/**
|
|
26
|
+
* Foreign table / collection name to join against. Must exist in the
|
|
27
|
+
* kit's schema — sqlitekit looks it up in the Drizzle schema map;
|
|
28
|
+
* mongokit resolves to a Model by collection name.
|
|
29
|
+
*/
|
|
30
|
+
from: string;
|
|
31
|
+
/** Field on the base row that holds the foreign key. */
|
|
32
|
+
localField: string;
|
|
33
|
+
/** Field on the joined row that matches `localField`. */
|
|
34
|
+
foreignField: string;
|
|
35
|
+
/**
|
|
36
|
+
* Output key where joined data lands. Defaults to `from` when
|
|
37
|
+
* omitted. Keep it explicit in application code — implicit
|
|
38
|
+
* defaults lead to name collisions when the foreign table name
|
|
39
|
+
* isn't a valid JavaScript identifier.
|
|
40
|
+
*/
|
|
41
|
+
as?: string;
|
|
42
|
+
/**
|
|
43
|
+
* When true, unwrap the join to a single object (or `null` when no
|
|
44
|
+
* row matches). Use for one-to-one and many-to-one relationships
|
|
45
|
+
* (e.g. a user's single department). Default is `false` — the join
|
|
46
|
+
* produces an array (empty when no rows match), matching
|
|
47
|
+
* one-to-many semantics.
|
|
48
|
+
*
|
|
49
|
+
* Kits implement this via `$unwind: { preserveNullAndEmptyArrays:
|
|
50
|
+
* true }` (mongokit) or a `json_object()` projection that skips the
|
|
51
|
+
* GROUP BY required for array aggregation (sqlitekit).
|
|
52
|
+
*/
|
|
53
|
+
single?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Project only these fields from the joined row. Accepts either a
|
|
56
|
+
* column-name array (`['id', 'name']`) or a MongoDB-style
|
|
57
|
+
* inclusion/exclusion map (`{ id: 1, name: 1 }`). Kits translate to
|
|
58
|
+
* their native projection primitive.
|
|
59
|
+
*
|
|
60
|
+
* Omitting `select` returns every column from the joined table.
|
|
61
|
+
* Prefer to narrow — it reduces network bytes and prevents
|
|
62
|
+
* accidental leaks of sensitive fields on the joined row.
|
|
63
|
+
*/
|
|
64
|
+
select?: readonly string[] | Record<string, 0 | 1>;
|
|
65
|
+
/**
|
|
66
|
+
* Optional pre-join filter on the joined rows. Compiled by the same
|
|
67
|
+
* Filter IR compiler each kit already uses for WHERE / `$match`.
|
|
68
|
+
* Narrows which foreign rows participate in the join — useful for
|
|
69
|
+
* soft-delete (`isNull('deletedAt')`) or status filters
|
|
70
|
+
* (`eq('status', 'active')`) applied to the joined side.
|
|
71
|
+
*
|
|
72
|
+
* NOTE: Applies to the foreign side only; filter the base side via
|
|
73
|
+
* the top-level `filters` on `lookupPopulate`.
|
|
74
|
+
*/
|
|
75
|
+
where?: Filter | Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Full options bag for `StandardRepo.lookupPopulate`. Same shape as the
|
|
79
|
+
* paginated read surface (`filters`, `sort`, `page`/`after`, `limit`)
|
|
80
|
+
* plus the `lookups` array. Drops straight into arc controllers that
|
|
81
|
+
* already build `PaginationParams` — just add the `lookups` field.
|
|
82
|
+
*
|
|
83
|
+
* Mixing offset vs keyset follows the same auto-detection rule as
|
|
84
|
+
* `getAll`: presence of `page` → offset, presence of `after` → keyset,
|
|
85
|
+
* neither → offset with `page: 1`.
|
|
86
|
+
*/
|
|
87
|
+
interface LookupPopulateOptions<TBase = unknown> {
|
|
88
|
+
/**
|
|
89
|
+
* Pre-join filter on the BASE table. Applied as `WHERE` / `$match`
|
|
90
|
+
* before joins run, so tenant scoping + soft-delete plugins compose
|
|
91
|
+
* correctly. Accepts Filter IR nodes or plain literal records; every
|
|
92
|
+
* kit's compiler handles both.
|
|
93
|
+
*/
|
|
94
|
+
filters?: Filter | (Partial<TBase> & Record<string, unknown>);
|
|
95
|
+
/** One or more joins. Processed in array order. */
|
|
96
|
+
lookups: readonly LookupSpec[];
|
|
97
|
+
/**
|
|
98
|
+
* Sort spec applied to the base table. Fields from joined rows are
|
|
99
|
+
* NOT sortable through this contract — they require the kit-native
|
|
100
|
+
* path (mongokit's `aggregatePipeline`, sqlitekit's raw query) because
|
|
101
|
+
* cross-kit semantics for sorting on denormalized joined payloads
|
|
102
|
+
* diverge significantly.
|
|
103
|
+
*/
|
|
104
|
+
sort?: string | Record<string, 1 | -1>;
|
|
105
|
+
/** 1-indexed page number for offset pagination. Defaults to `1`. */
|
|
106
|
+
page?: number;
|
|
107
|
+
/** Keyset cursor from a prior `next` field. */
|
|
108
|
+
after?: string;
|
|
109
|
+
/** Rows per page. Kit-dependent default (usually 20); capped at 1000. */
|
|
110
|
+
limit?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Base-table column projection. Applied before joins so kits can
|
|
113
|
+
* narrow the SELECT list early. Joined-row projections live on the
|
|
114
|
+
* individual `LookupSpec.select`.
|
|
115
|
+
*/
|
|
116
|
+
select?: readonly string[] | Record<string, 0 | 1>;
|
|
117
|
+
/**
|
|
118
|
+
* `'exact'` (default) runs a parallel count query for `total`;
|
|
119
|
+
* `'none'` skips the count entirely — the envelope reports
|
|
120
|
+
* `total: 0`, `pages: 0`, and derives `hasNext` from a `LIMIT N+1`
|
|
121
|
+
* peek on the data query. Use `'none'` for infinite-scroll UI where
|
|
122
|
+
* the total is never rendered.
|
|
123
|
+
*/
|
|
124
|
+
countStrategy?: 'exact' | 'none';
|
|
125
|
+
/**
|
|
126
|
+
* Transaction session, same semantics as every other read. Mongokit
|
|
127
|
+
* threads this into the aggregation; SQL kits ignore it when the
|
|
128
|
+
* repo is already bound to a tx via `withTransaction`.
|
|
129
|
+
*/
|
|
130
|
+
session?: unknown;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Row shape returned by `lookupPopulate`. Each row carries the base
|
|
134
|
+
* document plus one key per `LookupSpec.as` (defaulting to `from`).
|
|
135
|
+
* The joined value is:
|
|
136
|
+
*
|
|
137
|
+
* - an array of rows for `single: false` / default (one-to-many)
|
|
138
|
+
* - an object or `null` for `single: true` (one-to-one)
|
|
139
|
+
*
|
|
140
|
+
* `TBase` is the base row type; `TExtra` (defaults to `Record<string,
|
|
141
|
+
* unknown>`) is the aggregate shape of all joined payloads. Apps that
|
|
142
|
+
* want tight typing can supply `TExtra = { department: Department |
|
|
143
|
+
* null }` at the call site; callers that don't care just see
|
|
144
|
+
* `Record<string, unknown>`.
|
|
145
|
+
*/
|
|
146
|
+
type LookupRow<TBase = Record<string, unknown>, TExtra extends Record<string, unknown> = Record<string, unknown>> = TBase & TExtra;
|
|
147
|
+
/**
|
|
148
|
+
* Paginated result envelope for `lookupPopulate`. Mirrors `getAll`'s
|
|
149
|
+
* discriminated union — same `docs` / `page` / `limit` / `total` /
|
|
150
|
+
* `pages` / `hasNext` / `hasPrev` (offset) or `docs` / `limit` /
|
|
151
|
+
* `hasMore` / `next` (keyset) — so UI code paginates join results
|
|
152
|
+
* with the same primitives whether it's looking at plain documents
|
|
153
|
+
* or joined ones. Narrow on the `method` discriminator:
|
|
154
|
+
*
|
|
155
|
+
* ```ts
|
|
156
|
+
* const result = await repo.lookupPopulate({ ... });
|
|
157
|
+
* if (result.method === 'keyset') {
|
|
158
|
+
* // result.next, result.hasMore
|
|
159
|
+
* } else {
|
|
160
|
+
* // result.page, result.total, result.pages
|
|
161
|
+
* }
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* Kits that don't implement keyset joins simply never return the
|
|
165
|
+
* keyset variant — TypeScript's narrowing handles either case
|
|
166
|
+
* uniformly so callers don't branch on the kit.
|
|
167
|
+
*/
|
|
168
|
+
type LookupPopulateResult<TBase = Record<string, unknown>, TExtra extends Record<string, unknown> = Record<string, unknown>> = OffsetPaginationResultCore<LookupRow<TBase, TExtra>> | KeysetPaginationResultCore<LookupRow<TBase, TExtra>>;
|
|
169
|
+
//#endregion
|
|
170
|
+
export { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { CoreRepositoryOperation, OperationDescriptor, OperationRegistry, PolicyKey, RepositoryOperation } from "./types.mjs";
|
|
2
|
+
import { CORE_OP_REGISTRY, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations } from "./registry.mjs";
|
|
3
|
+
export { CORE_OP_REGISTRY, type CoreRepositoryOperation, type OperationDescriptor, type OperationRegistry, type PolicyKey, type RepositoryOperation, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { CORE_OP_REGISTRY, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations } from "./registry.mjs";
|
|
2
|
+
export { CORE_OP_REGISTRY, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CoreRepositoryOperation, OperationDescriptor, OperationRegistry, PolicyKey, RepositoryOperation } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/operations/registry.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The core registry. Driver kits merge their own descriptors on top of this
|
|
6
|
+
* via `extendRegistry`.
|
|
7
|
+
*/
|
|
8
|
+
declare const CORE_OP_REGISTRY: OperationRegistry<CoreRepositoryOperation>;
|
|
9
|
+
/**
|
|
10
|
+
* Merge additional operations into a base registry. Returns a frozen object.
|
|
11
|
+
*
|
|
12
|
+
* Kits call this at module load:
|
|
13
|
+
* ```ts
|
|
14
|
+
* export const MONGOKIT_OP_REGISTRY = extendRegistry(CORE_OP_REGISTRY, {
|
|
15
|
+
* aggregate: { policyKey: 'query', mutates: false, hasIdContext: false },
|
|
16
|
+
* aggregatePaginate: { policyKey: 'filters', mutates: false, hasIdContext: false },
|
|
17
|
+
* lookupPopulate: { policyKey: 'filters', mutates: false, hasIdContext: false },
|
|
18
|
+
* bulkWrite: { policyKey: 'operations', mutates: true, hasIdContext: false },
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The return type preserves both the base union and the extension keys, so
|
|
23
|
+
* plugins can still narrow on specific op names when they need to.
|
|
24
|
+
*/
|
|
25
|
+
declare function extendRegistry<Base extends string, Extra extends Record<string, OperationDescriptor>>(base: OperationRegistry<Base>, extra: Extra): OperationRegistry<Base | (keyof Extra & string)>;
|
|
26
|
+
/** All known operation names in the registry, in insertion order. */
|
|
27
|
+
declare function listOperations<Op extends string>(registry: OperationRegistry<Op>): Op[];
|
|
28
|
+
/** Operations that mutate the database — drives audit + cache invalidation. */
|
|
29
|
+
declare function mutatingOperations<Op extends string>(registry: OperationRegistry<Op>): Op[];
|
|
30
|
+
/** Operations that don't mutate — drives default cacheable-op lists. */
|
|
31
|
+
declare function readOperations<Op extends string>(registry: OperationRegistry<Op>): Op[];
|
|
32
|
+
/** Filter ops by their policy-injection key. */
|
|
33
|
+
declare function operationsByPolicyKey<Op extends string>(registry: OperationRegistry<Op>, key: PolicyKey): Op[];
|
|
34
|
+
/**
|
|
35
|
+
* Look up a descriptor. Returns `undefined` when the op isn't registered —
|
|
36
|
+
* plugins should treat an unknown op as "ignore" rather than crash, so new
|
|
37
|
+
* kits can introduce operations without every plugin needing an update.
|
|
38
|
+
*/
|
|
39
|
+
declare function describe<Op extends string>(registry: OperationRegistry<Op>, op: RepositoryOperation): OperationDescriptor | undefined;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { CORE_OP_REGISTRY, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//#region src/operations/registry.ts
|
|
2
|
+
/**
|
|
3
|
+
* The core registry. Driver kits merge their own descriptors on top of this
|
|
4
|
+
* via `extendRegistry`.
|
|
5
|
+
*/
|
|
6
|
+
const CORE_OP_REGISTRY = Object.freeze({
|
|
7
|
+
create: {
|
|
8
|
+
policyKey: "data",
|
|
9
|
+
mutates: true,
|
|
10
|
+
hasIdContext: false
|
|
11
|
+
},
|
|
12
|
+
update: {
|
|
13
|
+
policyKey: "query",
|
|
14
|
+
mutates: true,
|
|
15
|
+
hasIdContext: true
|
|
16
|
+
},
|
|
17
|
+
findOneAndUpdate: {
|
|
18
|
+
policyKey: "query",
|
|
19
|
+
mutates: true,
|
|
20
|
+
hasIdContext: false
|
|
21
|
+
},
|
|
22
|
+
delete: {
|
|
23
|
+
policyKey: "query",
|
|
24
|
+
mutates: true,
|
|
25
|
+
hasIdContext: true
|
|
26
|
+
},
|
|
27
|
+
restore: {
|
|
28
|
+
policyKey: "query",
|
|
29
|
+
mutates: true,
|
|
30
|
+
hasIdContext: true
|
|
31
|
+
},
|
|
32
|
+
createMany: {
|
|
33
|
+
policyKey: "dataArray",
|
|
34
|
+
mutates: true,
|
|
35
|
+
hasIdContext: false
|
|
36
|
+
},
|
|
37
|
+
updateMany: {
|
|
38
|
+
policyKey: "query",
|
|
39
|
+
mutates: true,
|
|
40
|
+
hasIdContext: false
|
|
41
|
+
},
|
|
42
|
+
deleteMany: {
|
|
43
|
+
policyKey: "query",
|
|
44
|
+
mutates: true,
|
|
45
|
+
hasIdContext: false
|
|
46
|
+
},
|
|
47
|
+
getById: {
|
|
48
|
+
policyKey: "query",
|
|
49
|
+
mutates: false,
|
|
50
|
+
hasIdContext: true
|
|
51
|
+
},
|
|
52
|
+
getByQuery: {
|
|
53
|
+
policyKey: "query",
|
|
54
|
+
mutates: false,
|
|
55
|
+
hasIdContext: false
|
|
56
|
+
},
|
|
57
|
+
getOne: {
|
|
58
|
+
policyKey: "query",
|
|
59
|
+
mutates: false,
|
|
60
|
+
hasIdContext: false
|
|
61
|
+
},
|
|
62
|
+
findAll: {
|
|
63
|
+
policyKey: "query",
|
|
64
|
+
mutates: false,
|
|
65
|
+
hasIdContext: false
|
|
66
|
+
},
|
|
67
|
+
getOrCreate: {
|
|
68
|
+
policyKey: "query",
|
|
69
|
+
mutates: false,
|
|
70
|
+
hasIdContext: false
|
|
71
|
+
},
|
|
72
|
+
count: {
|
|
73
|
+
policyKey: "query",
|
|
74
|
+
mutates: false,
|
|
75
|
+
hasIdContext: false
|
|
76
|
+
},
|
|
77
|
+
exists: {
|
|
78
|
+
policyKey: "query",
|
|
79
|
+
mutates: false,
|
|
80
|
+
hasIdContext: false
|
|
81
|
+
},
|
|
82
|
+
distinct: {
|
|
83
|
+
policyKey: "query",
|
|
84
|
+
mutates: false,
|
|
85
|
+
hasIdContext: false
|
|
86
|
+
},
|
|
87
|
+
getAll: {
|
|
88
|
+
policyKey: "filters",
|
|
89
|
+
mutates: false,
|
|
90
|
+
hasIdContext: false
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Merge additional operations into a base registry. Returns a frozen object.
|
|
95
|
+
*
|
|
96
|
+
* Kits call this at module load:
|
|
97
|
+
* ```ts
|
|
98
|
+
* export const MONGOKIT_OP_REGISTRY = extendRegistry(CORE_OP_REGISTRY, {
|
|
99
|
+
* aggregate: { policyKey: 'query', mutates: false, hasIdContext: false },
|
|
100
|
+
* aggregatePaginate: { policyKey: 'filters', mutates: false, hasIdContext: false },
|
|
101
|
+
* lookupPopulate: { policyKey: 'filters', mutates: false, hasIdContext: false },
|
|
102
|
+
* bulkWrite: { policyKey: 'operations', mutates: true, hasIdContext: false },
|
|
103
|
+
* });
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* The return type preserves both the base union and the extension keys, so
|
|
107
|
+
* plugins can still narrow on specific op names when they need to.
|
|
108
|
+
*/
|
|
109
|
+
function extendRegistry(base, extra) {
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
...base,
|
|
112
|
+
...extra
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** All known operation names in the registry, in insertion order. */
|
|
116
|
+
function listOperations(registry) {
|
|
117
|
+
return Object.keys(registry);
|
|
118
|
+
}
|
|
119
|
+
/** Operations that mutate the database — drives audit + cache invalidation. */
|
|
120
|
+
function mutatingOperations(registry) {
|
|
121
|
+
return listOperations(registry).filter((op) => registry[op].mutates);
|
|
122
|
+
}
|
|
123
|
+
/** Operations that don't mutate — drives default cacheable-op lists. */
|
|
124
|
+
function readOperations(registry) {
|
|
125
|
+
return listOperations(registry).filter((op) => !registry[op].mutates);
|
|
126
|
+
}
|
|
127
|
+
/** Filter ops by their policy-injection key. */
|
|
128
|
+
function operationsByPolicyKey(registry, key) {
|
|
129
|
+
return listOperations(registry).filter((op) => registry[op].policyKey === key);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Look up a descriptor. Returns `undefined` when the op isn't registered —
|
|
133
|
+
* plugins should treat an unknown op as "ignore" rather than crash, so new
|
|
134
|
+
* kits can introduce operations without every plugin needing an update.
|
|
135
|
+
*/
|
|
136
|
+
function describe(registry, op) {
|
|
137
|
+
return Object.hasOwn(registry, op) ? registry[op] : void 0;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { CORE_OP_REGISTRY, describe, extendRegistry, listOperations, mutatingOperations, operationsByPolicyKey, readOperations };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/operations/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Operation-registry type definitions.
|
|
4
|
+
*
|
|
5
|
+
* These types are the driver-agnostic classification of every repository
|
|
6
|
+
* operation — where a plugin should inject its scoping filter (for
|
|
7
|
+
* multi-tenant, soft-delete, etc.), whether the operation mutates, and
|
|
8
|
+
* whether it carries a primary-key id on the context. Every driver kit
|
|
9
|
+
* (mongokit, pgkit, prismakit) ships a registry with the same shape so
|
|
10
|
+
* cross-driver plugins work identically.
|
|
11
|
+
*/
|
|
12
|
+
/** Operations declared by `@classytic/repo-core`. Kits extend this union. */
|
|
13
|
+
type CoreRepositoryOperation = 'create' | 'update' | 'findOneAndUpdate' | 'delete' | 'restore' | 'createMany' | 'updateMany' | 'deleteMany' | 'getById' | 'getByQuery' | 'getOne' | 'findAll' | 'getOrCreate' | 'count' | 'exists' | 'distinct' | 'getAll';
|
|
14
|
+
/**
|
|
15
|
+
* Open repository-operation name. Kits extend the core union with their
|
|
16
|
+
* own native operations (e.g. mongokit's `aggregate`, `bulkWrite`). Plugins
|
|
17
|
+
* that walk a registry use this string type so they don't need to know the
|
|
18
|
+
* full union.
|
|
19
|
+
*/
|
|
20
|
+
type RepositoryOperation = CoreRepositoryOperation | (string & {});
|
|
21
|
+
/**
|
|
22
|
+
* Where a plugin should inject its scoping filter (multi-tenant scope,
|
|
23
|
+
* soft-delete filter, etc.) on the repository context.
|
|
24
|
+
*
|
|
25
|
+
* - `data` — single-doc create payload (`context.data`)
|
|
26
|
+
* - `dataArray` — multi-doc create payload (`context.dataArray`)
|
|
27
|
+
* - `query` — raw filter (`context.query`); the dominant convention
|
|
28
|
+
* - `filters` — paginated list options' filter sub-bag (`context.filters`)
|
|
29
|
+
* - `operations` — bulk-write per-sub-op (plugins walk each entry)
|
|
30
|
+
* - `none` — no scoping target (op accepts no filter input)
|
|
31
|
+
*/
|
|
32
|
+
type PolicyKey = 'data' | 'dataArray' | 'query' | 'filters' | 'operations' | 'none';
|
|
33
|
+
/** Classification of a single repository operation. */
|
|
34
|
+
interface OperationDescriptor {
|
|
35
|
+
/** Where multi-tenant / soft-delete plugins inject their scoping filter. */
|
|
36
|
+
readonly policyKey: PolicyKey;
|
|
37
|
+
/** Whether this op writes to the database. Drives audit + cache invalidation. */
|
|
38
|
+
readonly mutates: boolean;
|
|
39
|
+
/** True when `context.id` is populated by the time before/after hooks fire. */
|
|
40
|
+
readonly hasIdContext: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Registry shape. Indexed by operation name; values are operation descriptors.
|
|
44
|
+
* Kits compose their registry from `CORE_OP_REGISTRY` plus driver-specific
|
|
45
|
+
* additions (see `extendRegistry`).
|
|
46
|
+
*/
|
|
47
|
+
type OperationRegistry<Op extends string = RepositoryOperation> = Readonly<Record<Op, OperationDescriptor>>;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { CoreRepositoryOperation, OperationDescriptor, OperationRegistry, PolicyKey, RepositoryOperation };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DecodedCursor, SortSpec } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/pagination/cursor.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Encode a document's sort values and id into a base64url cursor token.
|
|
6
|
+
*
|
|
7
|
+
* The encoder accepts arbitrary string type tags beyond the core set; a kit
|
|
8
|
+
* that wants to tag an id as `'objectid'` can stamp that in the payload and
|
|
9
|
+
* decode it back on its side. Repo-core doesn't narrow unknown tags; they
|
|
10
|
+
* round-trip as `string` values.
|
|
11
|
+
*
|
|
12
|
+
* @param doc — document to extract cursor values from
|
|
13
|
+
* @param primaryField — primary sort field name (non-_id preferred, `_id` fallback)
|
|
14
|
+
* @param sort — normalized sort specification the cursor describes
|
|
15
|
+
* @param version — cursor format version; bump on breaking format changes
|
|
16
|
+
* @param tagValue — optional override for type tagging (kit extension point).
|
|
17
|
+
* When omitted, repo-core uses a minimal tagger that emits
|
|
18
|
+
* `date | boolean | number | string | null | unknown`.
|
|
19
|
+
*/
|
|
20
|
+
declare function encodeCursor(doc: Record<string, unknown>, primaryField: string, sort: SortSpec, version?: number, tagValue?: (value: unknown) => string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Decode a cursor token back into a structured payload.
|
|
23
|
+
*
|
|
24
|
+
* Accepts both URL-safe (`-`/`_`) and standard (`+`/`/`) base64 alphabets,
|
|
25
|
+
* so cursors emitted by mongokit ≤3.x (which used Node `Buffer` standard
|
|
26
|
+
* base64) remain decodable after a kit upgrade.
|
|
27
|
+
*
|
|
28
|
+
* Unknown type tags round-trip as strings — kits that need typed
|
|
29
|
+
* rehydration (`'objectid'` → `ObjectId` instance) can post-process the
|
|
30
|
+
* decoded cursor on their side.
|
|
31
|
+
*/
|
|
32
|
+
declare function decodeCursor(token: string): DecodedCursor;
|
|
33
|
+
/** Throw when the cursor's sort doesn't match the current query sort. */
|
|
34
|
+
declare function validateCursorSort(cursorSort: SortSpec, currentSort: SortSpec): void;
|
|
35
|
+
/**
|
|
36
|
+
* Validate cursor version against the server's accepted range.
|
|
37
|
+
*
|
|
38
|
+
* - Cursors newer than `expectedVersion` → client is ahead of server; reject.
|
|
39
|
+
* - Cursors older than `minVersion` → client cached a cursor from a
|
|
40
|
+
* pre-breaking-change deploy; reject so pagination restarts cleanly.
|
|
41
|
+
*/
|
|
42
|
+
declare function validateCursorVersion(cursorVersion: number, expectedVersion: number, minVersion?: number): void;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//#region src/pagination/cursor.ts
|
|
2
|
+
/** Core value types repo-core knows how to round-trip natively. */
|
|
3
|
+
const CORE_TYPES = [
|
|
4
|
+
"date",
|
|
5
|
+
"boolean",
|
|
6
|
+
"number",
|
|
7
|
+
"string",
|
|
8
|
+
"null",
|
|
9
|
+
"unknown"
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Encode a document's sort values and id into a base64url cursor token.
|
|
13
|
+
*
|
|
14
|
+
* The encoder accepts arbitrary string type tags beyond the core set; a kit
|
|
15
|
+
* that wants to tag an id as `'objectid'` can stamp that in the payload and
|
|
16
|
+
* decode it back on its side. Repo-core doesn't narrow unknown tags; they
|
|
17
|
+
* round-trip as `string` values.
|
|
18
|
+
*
|
|
19
|
+
* @param doc — document to extract cursor values from
|
|
20
|
+
* @param primaryField — primary sort field name (non-_id preferred, `_id` fallback)
|
|
21
|
+
* @param sort — normalized sort specification the cursor describes
|
|
22
|
+
* @param version — cursor format version; bump on breaking format changes
|
|
23
|
+
* @param tagValue — optional override for type tagging (kit extension point).
|
|
24
|
+
* When omitted, repo-core uses a minimal tagger that emits
|
|
25
|
+
* `date | boolean | number | string | null | unknown`.
|
|
26
|
+
*/
|
|
27
|
+
function encodeCursor(doc, primaryField, sort, version = 1, tagValue = defaultTagValue) {
|
|
28
|
+
const primaryValue = doc[primaryField];
|
|
29
|
+
const idValue = doc["_id"] ?? doc["id"];
|
|
30
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
31
|
+
const vals = {};
|
|
32
|
+
const types = {};
|
|
33
|
+
for (const field of sortFields) {
|
|
34
|
+
vals[field] = serializeValue(doc[field]);
|
|
35
|
+
types[field] = tagValue(doc[field]);
|
|
36
|
+
}
|
|
37
|
+
const payload = {
|
|
38
|
+
v: serializeValue(primaryValue),
|
|
39
|
+
t: tagValue(primaryValue),
|
|
40
|
+
id: String(serializeValue(idValue) ?? ""),
|
|
41
|
+
idType: tagValue(idValue),
|
|
42
|
+
sort,
|
|
43
|
+
ver: version,
|
|
44
|
+
...sortFields.length > 1 && {
|
|
45
|
+
vals,
|
|
46
|
+
types
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return base64urlEncode(JSON.stringify(payload));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decode a cursor token back into a structured payload.
|
|
53
|
+
*
|
|
54
|
+
* Accepts both URL-safe (`-`/`_`) and standard (`+`/`/`) base64 alphabets,
|
|
55
|
+
* so cursors emitted by mongokit ≤3.x (which used Node `Buffer` standard
|
|
56
|
+
* base64) remain decodable after a kit upgrade.
|
|
57
|
+
*
|
|
58
|
+
* Unknown type tags round-trip as strings — kits that need typed
|
|
59
|
+
* rehydration (`'objectid'` → `ObjectId` instance) can post-process the
|
|
60
|
+
* decoded cursor on their side.
|
|
61
|
+
*/
|
|
62
|
+
function decodeCursor(token) {
|
|
63
|
+
let json;
|
|
64
|
+
try {
|
|
65
|
+
json = base64urlDecode(token);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error("Invalid cursor token: not valid base64");
|
|
68
|
+
}
|
|
69
|
+
let payload;
|
|
70
|
+
try {
|
|
71
|
+
payload = JSON.parse(json);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error("Invalid cursor token: not valid JSON");
|
|
74
|
+
}
|
|
75
|
+
if (!isValidPayload(payload)) throw new Error("Invalid cursor token: malformed payload structure");
|
|
76
|
+
let values;
|
|
77
|
+
if (payload.vals && payload.types) {
|
|
78
|
+
values = {};
|
|
79
|
+
for (const [field, serialized] of Object.entries(payload.vals)) values[field] = rehydrateValue(serialized, payload.types[field] ?? "unknown");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
value: rehydrateValue(payload.v, payload.t),
|
|
83
|
+
id: rehydrateValue(payload.id, payload.idType),
|
|
84
|
+
sort: payload.sort,
|
|
85
|
+
version: payload.ver,
|
|
86
|
+
...values && { values }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Throw when the cursor's sort doesn't match the current query sort. */
|
|
90
|
+
function validateCursorSort(cursorSort, currentSort) {
|
|
91
|
+
if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw new Error("Cursor sort does not match current query sort");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Validate cursor version against the server's accepted range.
|
|
95
|
+
*
|
|
96
|
+
* - Cursors newer than `expectedVersion` → client is ahead of server; reject.
|
|
97
|
+
* - Cursors older than `minVersion` → client cached a cursor from a
|
|
98
|
+
* pre-breaking-change deploy; reject so pagination restarts cleanly.
|
|
99
|
+
*/
|
|
100
|
+
function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
|
|
101
|
+
if (cursorVersion > expectedVersion) throw new Error(`Cursor version ${String(cursorVersion)} is newer than expected version ${String(expectedVersion)}. Please upgrade.`);
|
|
102
|
+
if (cursorVersion < minVersion) throw new Error(`Cursor version ${String(cursorVersion)} is older than minimum supported ${String(minVersion)}. Pagination must restart.`);
|
|
103
|
+
}
|
|
104
|
+
function isValidPayload(payload) {
|
|
105
|
+
if (!payload || typeof payload !== "object") return false;
|
|
106
|
+
const p = payload;
|
|
107
|
+
return "v" in p && typeof p["t"] === "string" && typeof p["id"] === "string" && typeof p["idType"] === "string" && typeof p["sort"] === "object" && p["sort"] !== null && typeof p["ver"] === "number";
|
|
108
|
+
}
|
|
109
|
+
function serializeValue(value) {
|
|
110
|
+
if (value === null || value === void 0) return null;
|
|
111
|
+
if (value instanceof Date) return value.toISOString();
|
|
112
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
function defaultTagValue(value) {
|
|
116
|
+
if (value === null || value === void 0) return "null";
|
|
117
|
+
if (value instanceof Date) return "date";
|
|
118
|
+
if (typeof value === "boolean") return "boolean";
|
|
119
|
+
if (typeof value === "number") return "number";
|
|
120
|
+
if (typeof value === "string") return "string";
|
|
121
|
+
return "unknown";
|
|
122
|
+
}
|
|
123
|
+
function rehydrateValue(serialized, tag) {
|
|
124
|
+
if (tag === "null" || serialized === null) return null;
|
|
125
|
+
if (!CORE_TYPES.includes(tag)) return serialized;
|
|
126
|
+
switch (tag) {
|
|
127
|
+
case "date": return new Date(serialized);
|
|
128
|
+
case "boolean": return serialized === true || serialized === "true";
|
|
129
|
+
case "number": return Number(serialized);
|
|
130
|
+
case "string": return String(serialized);
|
|
131
|
+
default: return serialized;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function base64urlEncode(input) {
|
|
135
|
+
const bytes = new TextEncoder().encode(input);
|
|
136
|
+
let binary = "";
|
|
137
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
138
|
+
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
139
|
+
}
|
|
140
|
+
function base64urlDecode(token) {
|
|
141
|
+
const base64 = token.replace(/-/g, "+").replace(/_/g, "/");
|
|
142
|
+
const padLen = (4 - base64.length % 4) % 4;
|
|
143
|
+
const padded = base64 + "=".repeat(padLen);
|
|
144
|
+
const binary = globalThis.atob(padded);
|
|
145
|
+
const bytes = new Uint8Array(binary.length);
|
|
146
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
147
|
+
return new TextDecoder().decode(bytes);
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginationConfig, SortDirection, SortSpec, ValueType } from "./types.mjs";
|
|
2
|
+
import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
|
|
3
|
+
import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
|
|
4
|
+
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
|
|
5
|
+
export { type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, normalizeSort, shouldWarnDeepPagination, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
|
|
2
|
+
import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
|
|
3
|
+
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
|
|
4
|
+
export { calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, normalizeSort, shouldWarnDeepPagination, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SortSpec } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/pagination/keyset.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a sort object so non-`_id` fields come first, `_id` last.
|
|
6
|
+
* Stable ordering is required for cursor comparability across requests.
|
|
7
|
+
*/
|
|
8
|
+
declare function normalizeSort(sort: SortSpec): SortSpec;
|
|
9
|
+
/**
|
|
10
|
+
* Validate a sort spec for keyset pagination and return the normalized form.
|
|
11
|
+
*
|
|
12
|
+
* - Rejects empty sorts (keyset needs at least one field).
|
|
13
|
+
* - Rejects non-`±1` directions.
|
|
14
|
+
* - Rejects mixed directions across fields (keyset can't straddle directions).
|
|
15
|
+
* - Auto-adds `_id` as tie-breaker (matching the primary direction) when absent.
|
|
16
|
+
* - When `allowedPrimaryFields` is non-empty, rejects primary fields outside
|
|
17
|
+
* the allowlist (protects against lossy null-boundary keyset).
|
|
18
|
+
*/
|
|
19
|
+
declare function validateKeysetSort(sort: SortSpec, allowedPrimaryFields?: readonly string[]): SortSpec;
|
|
20
|
+
/** Invert every direction in a sort (ascending ↔ descending). */
|
|
21
|
+
declare function invertSort(sort: SortSpec): SortSpec;
|
|
22
|
+
/** Primary (first non-`_id`) sort field; falls back to `_id`. */
|
|
23
|
+
declare function getPrimaryField(sort: SortSpec): string;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { getPrimaryField, invertSort, normalizeSort, validateKeysetSort };
|