@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cache/index.d.mts +4 -0
  5. package/dist/cache/index.mjs +3 -0
  6. package/dist/cache/memory-adapter.d.mts +7 -0
  7. package/dist/cache/memory-adapter.mjs +37 -0
  8. package/dist/cache/stable-stringify.d.mts +15 -0
  9. package/dist/cache/stable-stringify.mjs +19 -0
  10. package/dist/cache/types.d.mts +59 -0
  11. package/dist/context/index.d.mts +2 -0
  12. package/dist/context/index.mjs +0 -0
  13. package/dist/context/types.d.mts +24 -0
  14. package/dist/errors/create-error.d.mts +19 -0
  15. package/dist/errors/create-error.mjs +23 -0
  16. package/dist/errors/duplicate-key.d.mts +38 -0
  17. package/dist/errors/duplicate-key.mjs +57 -0
  18. package/dist/errors/index.d.mts +4 -0
  19. package/dist/errors/index.mjs +3 -0
  20. package/dist/errors/types.d.mts +37 -0
  21. package/dist/filter/builders.d.mts +60 -0
  22. package/dist/filter/builders.mjs +172 -0
  23. package/dist/filter/guard.d.mts +13 -0
  24. package/dist/filter/guard.mjs +34 -0
  25. package/dist/filter/index.d.mts +7 -0
  26. package/dist/filter/index.mjs +6 -0
  27. package/dist/filter/match.d.mts +12 -0
  28. package/dist/filter/match.mjs +91 -0
  29. package/dist/filter/scope.d.mts +31 -0
  30. package/dist/filter/scope.mjs +54 -0
  31. package/dist/filter/types.d.mts +143 -0
  32. package/dist/filter/walk.d.mts +24 -0
  33. package/dist/filter/walk.mjs +77 -0
  34. package/dist/hooks/engine.d.mts +48 -0
  35. package/dist/hooks/engine.mjs +101 -0
  36. package/dist/hooks/events.d.mts +95 -0
  37. package/dist/hooks/events.mjs +93 -0
  38. package/dist/hooks/index.d.mts +5 -0
  39. package/dist/hooks/index.mjs +4 -0
  40. package/dist/hooks/priority.d.mts +23 -0
  41. package/dist/hooks/priority.mjs +21 -0
  42. package/dist/hooks/types.d.mts +37 -0
  43. package/dist/lookup/index.d.mts +2 -0
  44. package/dist/lookup/index.mjs +0 -0
  45. package/dist/lookup/types.d.mts +170 -0
  46. package/dist/operations/index.d.mts +3 -0
  47. package/dist/operations/index.mjs +2 -0
  48. package/dist/operations/registry.d.mts +41 -0
  49. package/dist/operations/registry.mjs +140 -0
  50. package/dist/operations/types.d.mts +49 -0
  51. package/dist/pagination/cursor.d.mts +44 -0
  52. package/dist/pagination/cursor.mjs +150 -0
  53. package/dist/pagination/index.d.mts +5 -0
  54. package/dist/pagination/index.mjs +4 -0
  55. package/dist/pagination/keyset.d.mts +25 -0
  56. package/dist/pagination/keyset.mjs +61 -0
  57. package/dist/pagination/offset.d.mts +26 -0
  58. package/dist/pagination/offset.mjs +47 -0
  59. package/dist/pagination/types.d.mts +136 -0
  60. package/dist/query-parser/coerce.d.mts +16 -0
  61. package/dist/query-parser/coerce.mjs +73 -0
  62. package/dist/query-parser/index.d.mts +4 -0
  63. package/dist/query-parser/index.mjs +3 -0
  64. package/dist/query-parser/parse-url.d.mts +7 -0
  65. package/dist/query-parser/parse-url.mjs +224 -0
  66. package/dist/query-parser/types.d.mts +104 -0
  67. package/dist/repository/base.d.mts +90 -0
  68. package/dist/repository/base.mjs +111 -0
  69. package/dist/repository/index.d.mts +5 -0
  70. package/dist/repository/index.mjs +3 -0
  71. package/dist/repository/plugin-types.d.mts +27 -0
  72. package/dist/repository/plugin-types.mjs +45 -0
  73. package/dist/repository/types.d.mts +470 -0
  74. package/dist/schema/field-rules.d.mts +62 -0
  75. package/dist/schema/field-rules.mjs +110 -0
  76. package/dist/schema/index.d.mts +3 -0
  77. package/dist/schema/index.mjs +2 -0
  78. package/dist/schema/types.d.mts +138 -0
  79. package/dist/testing/conformance.d.mts +6 -0
  80. package/dist/testing/conformance.mjs +481 -0
  81. package/dist/testing/index.d.mts +3 -0
  82. package/dist/testing/index.mjs +2 -0
  83. package/dist/testing/types.d.mts +113 -0
  84. 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 };