@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/_virtual/_rolldown/runtime.mjs +7 -0
  3. package/dist/adapter/index.d.mts +3 -0
  4. package/dist/adapter/index.mjs +2 -0
  5. package/dist/adapter/types.d.mts +222 -0
  6. package/dist/adapter/widen.d.mts +22 -0
  7. package/dist/adapter/widen.mjs +26 -0
  8. package/dist/aggregate/index.d.mts +3 -0
  9. package/dist/aggregate/index.mjs +3 -0
  10. package/dist/aggregate/keyset.d.mts +57 -0
  11. package/dist/aggregate/keyset.mjs +45 -0
  12. package/dist/aggregate/normalize.d.mts +24 -0
  13. package/dist/aggregate/normalize.mjs +28 -0
  14. package/dist/better-auth/index.d.mts +110 -0
  15. package/dist/better-auth/index.mjs +71 -0
  16. package/dist/cache/engine.d.mts +127 -0
  17. package/dist/cache/engine.mjs +235 -0
  18. package/dist/cache/envelope.mjs +32 -0
  19. package/dist/cache/index.d.mts +7 -2
  20. package/dist/cache/index.mjs +6 -2
  21. package/dist/cache/keys.mjs +131 -0
  22. package/dist/cache/memory-adapter.mjs +41 -7
  23. package/dist/cache/options.d.mts +112 -0
  24. package/dist/cache/options.mjs +25 -0
  25. package/dist/cache/plugin/context.d.mts +18 -0
  26. package/dist/cache/plugin/context.mjs +121 -0
  27. package/dist/cache/plugin/index.d.mts +86 -0
  28. package/dist/cache/plugin/index.mjs +78 -0
  29. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  30. package/dist/cache/plugin/read-hooks.mjs +96 -0
  31. package/dist/cache/plugin/swr.mjs +20 -0
  32. package/dist/cache/runtime.d.mts +43 -0
  33. package/dist/cache/runtime.mjs +14 -0
  34. package/dist/cache/tag-index.mjs +84 -0
  35. package/dist/cache/timeout-adapter.d.mts +30 -0
  36. package/dist/cache/timeout-adapter.mjs +58 -0
  37. package/dist/cache/types.d.mts +45 -0
  38. package/dist/cache/version-store.mjs +57 -0
  39. package/dist/errors/index.d.mts +2 -1
  40. package/dist/errors/index.mjs +2 -1
  41. package/dist/errors/schema.d.mts +101 -0
  42. package/dist/errors/schema.mjs +78 -0
  43. package/dist/filter/match.mjs +38 -2
  44. package/dist/lock/index.d.mts +132 -0
  45. package/dist/lock/index.mjs +162 -0
  46. package/dist/pagination/canonical.d.mts +8 -8
  47. package/dist/pagination/canonical.mjs +3 -9
  48. package/dist/pagination/cursor.mjs +4 -1
  49. package/dist/pagination/index.d.mts +2 -2
  50. package/dist/pagination/types.d.mts +17 -27
  51. package/dist/plugins/index.d.mts +2 -0
  52. package/dist/plugins/index.mjs +2 -0
  53. package/dist/plugins/tenant-helpers.d.mts +63 -0
  54. package/dist/plugins/tenant-helpers.mjs +84 -0
  55. package/dist/query-parser/index.d.mts +2 -1
  56. package/dist/query-parser/index.mjs +2 -1
  57. package/dist/query-parser/parse-url.mjs +13 -11
  58. package/dist/query-parser/reserved.d.mts +43 -0
  59. package/dist/query-parser/reserved.mjs +56 -0
  60. package/dist/repository/agg-output.d.mts +63 -0
  61. package/dist/repository/agg-output.mjs +89 -0
  62. package/dist/repository/index.d.mts +4 -2
  63. package/dist/repository/index.mjs +3 -1
  64. package/dist/repository/options.d.mts +62 -0
  65. package/dist/repository/options.mjs +57 -0
  66. package/dist/repository/types.d.mts +936 -49
  67. package/dist/schema/field-rules.d.mts +41 -1
  68. package/dist/schema/field-rules.mjs +92 -1
  69. package/dist/schema/index.d.mts +2 -2
  70. package/dist/schema/index.mjs +2 -2
  71. package/dist/schema/types.d.mts +21 -0
  72. package/dist/testing/conformance.mjs +666 -17
  73. package/dist/testing/index.d.mts +3 -2
  74. package/dist/testing/index.mjs +2 -1
  75. package/dist/testing/lock-conformance.d.mts +25 -0
  76. package/dist/testing/lock-conformance.mjs +167 -0
  77. package/dist/testing/types.d.mts +99 -2
  78. package/package.json +23 -1
  79. package/dist/cache/stable-stringify.d.mts +0 -15
package/CHANGELOG.md CHANGED
@@ -4,6 +4,249 @@ All notable changes to `@classytic/repo-core` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-05-04
8
+
9
+ ### Added — kit-shared building blocks (consolidation)
10
+
11
+ - **`@classytic/repo-core/aggregate`** (new subpath) — kit-neutral aggregate IR helpers that every backend's compiler consumes identically: `normalizeGroupBy`, `validateMeasures`, `encodeAggCursor`, `decodeAggCursor`, `isKeysetMode`, `DecodedCursor`. mongokit + sqlitekit shipped byte-identical copies of these for the prior several releases; promoting them here keeps the IR contract honest. The driver-specific predicate builders (`buildKeysetPredicate` in mongokit, `buildKeysetHaving` in sqlitekit) stay kit-local.
12
+ - **`@classytic/repo-core/plugins`** (new subpath) — kit-neutral plugin building blocks. Currently exports `payloadHasTenantField` (handles all 5 policy keys: data, dataArray, query, filters, operations) and `adminBypass` (skipWhen-compatible role-bypass factory). Both kits now consume these instead of shipping their own copies. Sqlitekit gains `adminBypass` for free as a side effect.
13
+
14
+ ### ⚠️ BREAKING — `cache/deferred.ts` removed (use `Promise.withResolvers()`)
15
+
16
+ - **`createDeferred()` and `Deferred<T>` no longer exported from `@classytic/repo-core/cache`.** Both were thin wrappers around `Promise.withResolvers()`, which Node 22+ ships natively (the package's platform floor). The cache engine's single-flight map now uses the native primitive directly with zero indirection.
17
+ - **Migration:** if you imported them, replace `import { createDeferred } from '@classytic/repo-core/cache'` with `Promise.withResolvers<T>()`. Same shape (`{ promise, resolve, reject }`); behavior is identical.
18
+
19
+ ### Fixed — security & robustness hardening
20
+
21
+ - **DoS surface in URL parser**: `parseUrl` now drops parameter keys longer than 256 chars before bracket-regex parsing. Without the cap, a hostile 1MB key forced repeated full-string regex scans. Legitimate URL params don't approach the bound. (`src/query-parser/parse-url.ts`)
22
+ - **Cursor payload type validation**: `decodeCursor` now type-checks every payload field, not just presence. A corrupted token shaped `{ v: { evil: true } }` previously slipped past the `'v' in p` guard and produced opaque errors downstream. (`src/pagination/cursor.ts`)
23
+ - **Regex compile-per-doc on in-memory filter**: `matchFilter`'s `like` and `regex` cases now use a bounded LRU cache (256 entries) keyed by `(pattern, flags)`. Prior implementation compiled a fresh `RegExp` on every call — measurable cost when `asPredicate(filter)` runs over 100k docs. (`src/filter/match.ts`)
24
+
25
+ ### Cache hash upgrade (djb2 → FNV-1a 64-bit)
26
+
27
+ - **`buildCacheKey` now uses FNV-1a 64-bit** (was djb2 32-bit). At multi-tenant fleet scale (10k tenants × dozens of cached aggregations each), djb2's 32-bit space hit ~50% birthday-paradox collision around 65k distinct keys. FNV-1a 64-bit pushes that threshold to ~4B keys. Same call site, same key shape, same regex-compatible base-36 output.
28
+ - **Operational note for deployment:** existing Redis entries hash differently after the swap, so cold cache for one TTL cycle post-deploy. The collection-version orphan path already handles this for write-driven invalidation.
29
+
30
+ ### Cache layer — atomic counters, parallel invalidation, prefetch, timeout
31
+
32
+ - **`CacheAdapter.increment(key, by, ttl)`** — optional atomic counter primitive. When the adapter ships it (Redis `INCRBY`, in-memory `Map`, future driver-native impls), `bumpModelVersion` produces strictly-monotonic versions across concurrent multi-pod writes — no lost bumps. Adapters without `increment` (Cloudflare KV, etc.) fall back to `get → max → set`; correct in single-pod, accepts a tiny race window in multi-pod (mitigated by `Date.now()` floor).
33
+ - **Parallelized `invalidateByTags`** — fan-out reads + fan-out deletes via `Promise.all`. For Redis-backed adapters with pipelining, 5 tags × 100 keys completes in ~2 RTTs instead of 510. ~10× speedup on hot tags.
34
+ - **`CacheEngine.prefetch(key, opts, fetcher)`** — TanStack-equivalent cache warming. Single-flight semantics: 100 concurrent prefetches for the same key run the fetcher exactly once. Returns cached on hit, fetches + stores on miss, dedupes via the engine's pending map.
35
+ - **`withTimeout(adapter, { ms, onTimeout, onSlow })`** — adapter decorator that fail-fasts on slow backends. `onTimeout: 'miss'` (default) makes slow gets behave as cache misses (kit serves uncached); `'throw'` propagates `CacheTimeoutError`. `onSlow` callback for observability.
36
+ - **`scheduleBackground`** is now a public export from `@classytic/repo-core/cache` — hosts can use the same cross-runtime primitive for their own post-response work.
37
+
38
+ ### Cache layer — production hardening (TanStack-aligned) + cross-runtime
39
+
40
+ Six gaps in the v1 unified cache layer fixed before any prod traffic:
41
+
42
+ 1. **Single-flight on miss** — `CacheEngine.claimPending()` / `getPending()` / `resolvePending()` / `rejectPending()`. Concurrent misses for the same key wait on the first claimer's promise instead of running N redundant fetches. Cache-stampede prevention; TanStack `QueryClient`-equivalent.
43
+ 2. **Per-scope version-bump** — `bumpModelVersion(model, scopeKey?)` and `getModelVersion(model, scopeKey?)`. Writes inside `org:abc` no longer invalidate `org:xyz`'s cached reads. Targeted invalidation; matches TanStack's "exact match" semantics.
44
+ 3. **Cross-runtime SWR scheduling** — new `runtime.ts` exposes `scheduleBackground` that picks `setImmediate` on Node / Bun and `setTimeout(0)` on Cloudflare Workers / Deno Deploy / browser. Either way the callback fires after the current sync block + microtask queue, ensuring the user's response writes to the socket BEFORE the bg fetch's first await. (Old impl used `setImmediate` directly — `ReferenceError` on edge runtimes.)
45
+ 4. **TTL-bounded tag index** — index entries inherit their cached entries' TTLs (capped at 24h). Old impl used `ttlSeconds: 0` ("never expire" in Redis); side-index grew unboundedly on hot tags.
46
+ 5. **`error:<op>` rejects pending** — when the claimer's executor errors, the plugin's `error:<op>` hook rejects the deferred so single-flight waiters fail-fast. No hanging promises, no double-fetch on transient backend failures.
47
+ 6. **Allowlist-per-op shape keys** — `DEFAULT_SHAPE_KEYS_BY_OP` maps each read op to the fields that actually affect result shape. Only those fields participate in the cache key. Replaces the prior denylist (which would silently include any new context field a kit added — exploding miss rates if e.g. `requestId` slipped through). Hosts can override per-op via `cachePlugin({ shapeKeysByOp })`.
48
+
49
+ ### Code organization
50
+
51
+ The unified plugin (`@classytic/repo-core/cache`) is now split into focused modules:
52
+
53
+ ```
54
+ cache/
55
+ plugin/
56
+ index.ts # cachePlugin factory + types + handle (~200 LOC)
57
+ context.ts # typed context slots + extraction + shape-keys (~170 LOC)
58
+ read-hooks.ts # before/after/error for read ops (~180 LOC)
59
+ invalidation-hooks.ts # after for write ops (~60 LOC)
60
+ swr.ts # background-refresh scheduler (~50 LOC)
61
+ engine.ts # TTL + SWR + tag + version + single-flight (~220 LOC)
62
+ runtime.ts # cross-runtime scheduleBackground (~50 LOC)
63
+ ...
64
+ ```
65
+
66
+ Each module has one purpose. Replaces the prior 564-LOC `plugin.ts` mega-file.
67
+
68
+ ### Added — `Deferred<T>` utility
69
+
70
+ `createDeferred<T>()` exported from `@classytic/repo-core/cache` — a Promise plus its `resolve`/`reject` handles, externalized. Same primitive `Promise.withResolvers()` provides natively in Node 22+; we ship our own to keep the contract explicit and support older runtimes.
71
+
72
+ ### Added — Unified cache layer (`@classytic/repo-core/cache`)
73
+
74
+ One `cachePlugin({ adapter })` for every kit + arc + Express/Nest hosts. Replaces three independent SWR/TTL/tag implementations (mongokit's CRUD `cachePlugin` + aggregate `withAggCache`, sqlitekit's local `cachePlugin`, arc's `QueryCache`) with one canonical hook integration.
75
+
76
+ #### Public surface (`@classytic/repo-core/cache`)
77
+
78
+ ```ts
79
+ import {
80
+ cachePlugin, // hook integration — plugs into RepositoryBase
81
+ CacheEngine, // direct SWR + TTL + tag flow over a CacheAdapter
82
+ buildEnvelope, inspectEnvelope, type CacheEnvelope,
83
+ buildCacheKey, extractScopeTags, type BuildKeyInput,
84
+ appendKeyToTags, invalidateByTags as invalidateByTagsImpl,
85
+ bumpModelVersion, getModelVersion,
86
+ resolveCacheOptions, type CacheOptions, type ResolvedCacheOptions, type CacheReadResult,
87
+ // already shipped:
88
+ type CacheAdapter, createMemoryCacheAdapter, stableStringify,
89
+ } from '@classytic/repo-core/cache';
90
+ ```
91
+
92
+ #### TanStack Query-shaped per-call options
93
+
94
+ Same shape across CRUD + aggregate, kit-agnostic:
95
+
96
+ ```ts
97
+ {
98
+ staleTime?: number; // seconds fresh
99
+ gcTime?: number; // seconds retained past stale (default 60)
100
+ swr?: boolean; // serve-stale + bg refresh
101
+ tags?: readonly string[];
102
+ bypass?: boolean;
103
+ enabled?: boolean;
104
+ key?: string; // explicit override
105
+ }
106
+ ```
107
+
108
+ #### What the plugin does
109
+
110
+ 1. Subscribes to `before:<op>` / `after:<op>` for every read op (`getById`, `getAll`, `getOne`, `getByQuery`, `count`, `exists`, `distinct`, `aggregate`, `aggregatePaginate`) — configurable via `enabled: [...]`.
111
+ 2. Subscribes to `after:<op>` for every mutating op (`create`, `update`, `delete`, `claim`, ...) — configurable via `invalidating: [...]`. Bumps the model's version (orphans every cached read in O(1)) AND invalidates the model-tag (cross-aggregation invalidation).
112
+ 3. Auto-injects scope tags (`org:<id>`, `user:<id>`) from `context.filter` so cross-tenant cache poisoning is structurally impossible.
113
+ 4. Hooks register at `HOOK_PRIORITY.CACHE` (200) — multi-tenant + soft-delete (POLICY = 100) run first so their filter mutations land in the cache key.
114
+ 5. Attaches `repo.cache` handle exposing `invalidateByTags(tags)`, `bumpModelVersion(model)`, `clear()`.
115
+
116
+ #### Strictly-monotonic version bumps
117
+
118
+ `bumpModelVersion` uses `max(Date.now(), previous + 1)` so same-millisecond writes (cache prime + write hit at the same ms) don't collide, fixing a real correctness gap the prior `Date.now()`-only impl had.
119
+
120
+ #### `AggCacheOptions` is now an alias for `CacheOptions`
121
+
122
+ Same shape across CRUD + aggregate. Old field names (`ttl`, `staleWhileRevalidate`) removed — migrate to `staleTime`, `swr`. Ecosystem packages (mongokit, sqlitekit, arc) all consume the unified type.
123
+
124
+ #### Removed
125
+
126
+ - `/aggregate-cache` subpath — superseded by the unified `/cache` plugin (which handles aggregate ops natively via the `before:aggregate` hook).
127
+
128
+ #### Migration (kit + host)
129
+
130
+ ```ts
131
+ // Before — kit-specific cache plugins + constructor option
132
+ new Repository(model, [cachePlugin({ adapter, ttlSeconds: 60 })], {}, {
133
+ aggregateCache: adapter, // separate constructor option
134
+ });
135
+ repo.aggregate({ measures, cache: { ttl: 60, staleWhileRevalidate: true } });
136
+
137
+ // After — one plugin, one shape
138
+ new Repository(model, [
139
+ multiTenantPlugin({ tenantField: 'orgId' }),
140
+ cachePlugin({ adapter, defaults: { staleTime: 60, gcTime: 300, swr: true } }),
141
+ ]);
142
+ repo.aggregate({ measures, cache: { staleTime: 60, swr: true, tags: ['orders'] } });
143
+ repo.getAll(filter, { cache: { staleTime: 30 } });
144
+ await repo.cache?.invalidateByTags(['orders']);
145
+ ```
146
+
147
+ ### Added — `StandardRepo.claim()` and `claimVersion()` (atomic CAS, REQUIRED on the contract)
148
+
149
+ Standardizes the canonical state-machine write that every domain package was hand-rolling on top of `findOneAndUpdate`:
150
+
151
+ ```ts
152
+ const claimed = await repo.claim?.(runId, { from: 'waiting', to: 'running' }, {
153
+ lastHeartbeat: new Date(),
154
+ workerId: 'worker-12',
155
+ });
156
+ if (!claimed) return; // someone else got it
157
+ ```
158
+
159
+ **Cross-kit portable.** Mongokit compiles to `findOneAndUpdate({ _id, status: from }, { $set: { status: to, ...patch } })`. SQL kits compile to `UPDATE x SET ... WHERE id = ? AND status = <from> RETURNING *`. Prismakit compiles to `prisma.x.updateMany({ where: { id, status: from }, data: ... })` followed by a `findUnique` when `count > 0`. Same input, same null-on-race semantics across every backend.
160
+
161
+ **Pairs with `@classytic/primitives/state-machine`** — different layers:
162
+ - `defineStateMachine()` answers "is `from → to` legal in the model?" (compile-time table + early throw)
163
+ - `claim()` answers "did we win the transition vs concurrent writers?" (runtime null on race)
164
+
165
+ The state field defaults to `'status'` (matches the convention across `streamline`, `@classytic/order`, `revenue`, `invoice`); pass `{ field: 'phase', from, to }` for state machines keyed off a different column.
166
+
167
+ #### New types exported from `@classytic/repo-core/repository`
168
+
169
+ - `ClaimTransition` — `{ field?, from, to, where? }` argument shape for `claim` (`where` is the compound-CAS predicate slot — see below).
170
+ - `ClaimVersionTransition` — `{ field?, from: number | undefined, by?, where? }` argument shape for `claimVersion`. `from === undefined` is admitted for first-write CAS (matches docs whose version field is null OR missing).
171
+
172
+ #### Added to `StandardRepo<TDoc>` — REQUIRED methods (not optional)
173
+
174
+ ```ts
175
+ claim(
176
+ id: string,
177
+ transition: ClaimTransition,
178
+ patch?: Partial<TDoc>,
179
+ options?: WriteOptions,
180
+ ): Promise<TDoc | null>;
181
+
182
+ claimVersion(
183
+ id: string,
184
+ transition: ClaimVersionTransition,
185
+ update: Record<string, unknown>,
186
+ options?: WriteOptions,
187
+ ): Promise<TDoc | null>;
188
+ ```
189
+
190
+ **Required, not optional.** During pre-release dev iterations, `claim?` was optional as scaffolding while kits implemented. Both mongokit and sqlitekit ship them as concrete class primitives, and downstream domain packages (~10 in the classytic codebase) carry FSM verbs depending on them — none gracefully degrade. Required-on-the-contract removes the `if (repo.claim) { ... }` boilerplate at every call site and surfaces missing implementations at the conformance gate instead of at runtime.
191
+
192
+ #### `ClaimTransition.where` — compound-CAS predicate
193
+
194
+ Real-world audit (streamline, commission, yard, revenue, order, invoice): the bare `{ [idField]: id, [field]: from }` filter shape fits ~5% of atomic-claim sites in production. The other 95% carry compound predicates — paused guards, retry-time guards, heartbeat-staleness, sub-document `$elemMatch`, `$or` for missing-or-stale fields. Without a way to express those, `claim()` covered the textbook example but couldn't replace the hand-rolled CAS calls in production.
195
+
196
+ `ClaimTransition.where` AND-merges arbitrary predicates alongside the canonical id + state-field match:
197
+
198
+ ```ts
199
+ const claimed = await repo.claim?.(runId, {
200
+ from: 'waiting',
201
+ to: 'running',
202
+ where: {
203
+ paused: { $ne: true },
204
+ 'scheduling.retryAfter': { $lte: new Date() },
205
+ },
206
+ }, { lastHeartbeat: new Date() });
207
+ ```
208
+
209
+ Cross-kit notes:
210
+ - Mongokit: ANDed into the `findOneAndUpdate` filter.
211
+ - SQL kits: ANDed into the `WHERE` clause (raw column literals accepted; portable Filter IR is compiled).
212
+ - Prismakit: merged as additional keys on the `where` object.
213
+
214
+ Null-on-race semantics unchanged — if no doc matches the full compound filter (state OR any `where` predicate), `claim` returns `null`. The caller can't distinguish "lost race" from "guard predicate failed"; both mean "don't proceed."
215
+
216
+ Driven by streamline's audit (1 of 21 sites fit the bare shape; 21 of 21 fit the compound shape). Same pattern across the other audited packages.
217
+
218
+ #### `ClaimTransition.from` widened to `unknown | readonly unknown[]` — multi-source CAS
219
+
220
+ Single-value `from` covers the textbook one-source transition (`waiting → running`). Real-world state machines also need to claim from one of multiple source states — commission's `voidRecord` / `markClawedBack` / `endAgreement` / `_transition` (4 sites), media-kit's `pending|processing → error` catch-block. `from` now accepts an array; kit compilers emit `[stateField] IN (...)` (SQL) or `[stateField]: { $in: [...] }` (mongo).
221
+
222
+ ```ts
223
+ // "From any non-terminal state to voided"
224
+ await repo.claim?.(id, { from: ['pending', 'approved', 'sent'], to: 'voided' });
225
+ ```
226
+
227
+ Single-value `from` is unchanged (back-compatible). Array form is opt-in — pass an array to enable.
228
+
229
+ **`from === to` is allowed** — the documented idempotent re-claim semantic. Yard's `reviseDeparture` writes `departed → departed` to atomically refresh the row's payload while asserting it hasn't moved on. The CAS still returns `null` if the row left the source state, so race-loss semantics hold.
230
+
231
+ #### Migration
232
+
233
+ Pre-0.4.0 callers wrote:
234
+
235
+ ```ts
236
+ const claimed = await repo.findOneAndUpdate(
237
+ { _id: id, status: 'waiting' },
238
+ { $set: { status: 'running', lastHeartbeat: new Date() }, },
239
+ );
240
+ ```
241
+
242
+ Post-0.4.0:
243
+
244
+ ```ts
245
+ const claimed = await repo.claim?.(id, { from: 'waiting', to: 'running' }, { lastHeartbeat: new Date() });
246
+ ```
247
+
248
+ The old form keeps working — `claim()` is an additive optional method, not a rename.
249
+
7
250
  ## [0.3.0] - 2026-04-29
8
251
 
9
252
  ### Added — Aggregate pagination shapes
@@ -0,0 +1,7 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
3
+ if (typeof require !== "undefined") return require.apply(this, arguments);
4
+ throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
5
+ });
6
+ //#endregion
7
+ export { __require };
@@ -0,0 +1,3 @@
1
+ import { AdapterFactory, AdapterRepositoryInput, AdapterSchemaContext, AdapterValidationResult, DataAdapter, FieldMetadata, OpenApiSchemas, RelationMetadata, RepositoryLike, SchemaMetadata } from "./types.mjs";
2
+ import { asRepositoryLike, isRepository } from "./widen.mjs";
3
+ export { type AdapterFactory, type AdapterRepositoryInput, type AdapterSchemaContext, type AdapterValidationResult, type DataAdapter, type FieldMetadata, type OpenApiSchemas, type RelationMetadata, type RepositoryLike, type SchemaMetadata, asRepositoryLike, isRepository };
@@ -0,0 +1,2 @@
1
+ import { asRepositoryLike, isRepository } from "./widen.mjs";
2
+ export { asRepositoryLike, isRepository };
@@ -0,0 +1,222 @@
1
+ import { DeleteResult, MinimalRepo, StandardRepo } from "../repository/types.mjs";
2
+ import { SchemaBuilderOptions } from "../schema/types.mjs";
3
+
4
+ //#region src/adapter/types.d.ts
5
+ /**
6
+ * Cross-kit repository contract.
7
+ *
8
+ * Defined as `MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>` — the
9
+ * 5-method floor every kit must implement, plus every other
10
+ * `StandardRepo` method (atomic CAS, batch ops, aggregation, soft-delete,
11
+ * transactions) as optional. Hosts feature-detect optional methods at
12
+ * call sites; kits declare only what they implement.
13
+ *
14
+ * **Why compound and not `StandardRepo` alone:** forcing every kit to
15
+ * implement the full surface would break kits with partial capabilities
16
+ * (sqlitekit has no aggregation, prismakit has no native atomic CAS the
17
+ * same way). Hosts use `typeof repo.method === 'function'` checks at
18
+ * construction.
19
+ *
20
+ * **Why compound and not `MinimalRepo` alone:** internal subsystems
21
+ * (audit, outbox, idempotency stores) need `StandardRepo` type info at
22
+ * call sites. `Partial<StandardRepo>` keeps the type-level backing
23
+ * without forcing every kit to implement everything.
24
+ */
25
+ type RepositoryLike<TDoc = unknown> = MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>;
26
+ /**
27
+ * Permissive structural input accepted at every adapter factory boundary.
28
+ *
29
+ * Wider than `RepositoryLike<TDoc>` on `getAll`'s `params`/`options` —
30
+ * uses method-shorthand syntax with `unknown` so kit-native repository
31
+ * classes plug in directly without `as RepositoryLike<TDoc>` casts on
32
+ * the host.
33
+ *
34
+ * **Why this exists.** repo-core 0.2 widened `MinimalRepo['getAll']`'s
35
+ * `params.filters` to a `Filter | Record<string, unknown>` IR union, but
36
+ * concrete kit `Repository` classes still type `filters` as the narrower
37
+ * `Record<string, unknown>`. Under `strictFunctionTypes` the kit's
38
+ * narrower function-property `getAll` is no longer assignable to the
39
+ * IR-aware one, which forced every host adapter glue file to write
40
+ * `repository as unknown as RepositoryLike<TDoc>`.
41
+ *
42
+ * Adapter factories accept this permissive shape, then call
43
+ * `asRepositoryLike()` once to widen for host internals (which still see
44
+ * the strict `RepositoryLike` view). The documented escape hatch lives
45
+ * in repo-core, not at every host call site.
46
+ */
47
+ interface AdapterRepositoryInput<TDoc = unknown> {
48
+ readonly idField?: string;
49
+ getAll(params?: unknown, options?: unknown): Promise<unknown>;
50
+ getById(id: string, options?: unknown): Promise<TDoc | null>;
51
+ create(data: Partial<TDoc>, options?: unknown): Promise<TDoc>;
52
+ update(id: string, data: Partial<TDoc>, options?: unknown): Promise<TDoc | null>;
53
+ delete(id: string, options?: unknown): Promise<DeleteResult | null>;
54
+ }
55
+ /**
56
+ * Generic OpenAPI-shaped schema bag emitted by `DataAdapter.generateSchemas`.
57
+ *
58
+ * Loose `unknown` slots so kits emit JSON Schema or kit-native shapes
59
+ * without type pressure. Hosts that want a specific shape (Fastify route
60
+ * schemas, Zod models, ...) narrow on consumption.
61
+ */
62
+ interface OpenApiSchemas {
63
+ /** Resource entity schema (the row shape). */
64
+ entity?: unknown;
65
+ /** Create-body schema (POST / PUT body). */
66
+ createBody?: unknown;
67
+ /** Update-body schema (PATCH body). */
68
+ updateBody?: unknown;
69
+ /** Path-params schema (`/:id`). */
70
+ params?: unknown;
71
+ /** List-query querystring schema (filtering / pagination / sort). */
72
+ listQuery?: unknown;
73
+ /**
74
+ * Response schema for OpenAPI documentation. Auto-derived from the
75
+ * entity / create-body shape if omitted.
76
+ */
77
+ response?: unknown;
78
+ [key: string]: unknown;
79
+ }
80
+ /**
81
+ * Context passed to `adapter.generateSchemas()` so adapters shape output
82
+ * to match host-level configuration. All fields optional — adapters that
83
+ * ignore this still work; the host applies its own normalization.
84
+ */
85
+ interface AdapterSchemaContext {
86
+ /** The `idField` configured on the resource. Defaults to `_id`. */
87
+ idField?: string;
88
+ /** Resource name (for error messages / logging). */
89
+ resourceName?: string;
90
+ }
91
+ /**
92
+ * Field-level metadata returned by `getSchemaMetadata()`. JSON-Schema-
93
+ * adjacent vocabulary kept driver-free so introspection tooling can
94
+ * consume any kit's output uniformly.
95
+ */
96
+ interface FieldMetadata {
97
+ type: 'string' | 'number' | 'boolean' | 'date' | 'object' | 'array' | 'objectId' | 'enum';
98
+ required?: boolean;
99
+ unique?: boolean;
100
+ default?: unknown;
101
+ enum?: Array<string | number>;
102
+ min?: number;
103
+ max?: number;
104
+ minLength?: number;
105
+ maxLength?: number;
106
+ pattern?: string;
107
+ description?: string;
108
+ ref?: string;
109
+ array?: boolean;
110
+ }
111
+ /**
112
+ * Relation metadata returned by `getSchemaMetadata()`.
113
+ */
114
+ interface RelationMetadata {
115
+ type: 'one-to-one' | 'one-to-many' | 'many-to-many';
116
+ target: string;
117
+ foreignKey?: string;
118
+ through?: string;
119
+ }
120
+ /**
121
+ * Schema metadata returned by `getSchemaMetadata()`. Shape-only — no kit
122
+ * types leak through.
123
+ */
124
+ interface SchemaMetadata {
125
+ name: string;
126
+ fields: Record<string, FieldMetadata>;
127
+ indexes?: Array<{
128
+ fields: string[];
129
+ unique?: boolean;
130
+ sparse?: boolean;
131
+ }>;
132
+ relations?: Record<string, RelationMetadata>;
133
+ }
134
+ /**
135
+ * Result of `adapter.validate()`. Structurally identical to repo-core's
136
+ * `schema/types.ts` `ValidationResult` for the message/violations shape,
137
+ * but uses `errors[]` (the OpenAPI/AJV convention) for adapter-time
138
+ * validation. Kept distinct so adapter validation and update-body
139
+ * validation can evolve independently.
140
+ */
141
+ interface AdapterValidationResult {
142
+ valid: boolean;
143
+ errors?: Array<{
144
+ field: string;
145
+ message: string;
146
+ code?: string;
147
+ }>;
148
+ }
149
+ /**
150
+ * Cross-framework data-adapter contract.
151
+ *
152
+ * A kit's `createXxxAdapter()` factory produces an instance of this
153
+ * interface. Frameworks (arc, custom hosts) consume the same shape; the
154
+ * kit never imports the framework.
155
+ */
156
+ interface DataAdapter<TDoc = unknown> {
157
+ /**
158
+ * Repository implementing CRUD operations. Any value that satisfies
159
+ * `RepositoryLike<TDoc>` — which includes `StandardRepo<TDoc>` (all
160
+ * methods implemented), `MinimalRepo<TDoc>` (5-method floor), or
161
+ * anything in between a kit declares. Hosts feature-detect optional
162
+ * methods at runtime.
163
+ */
164
+ repository: RepositoryLike<TDoc>;
165
+ /** Adapter identifier for introspection. */
166
+ readonly type: 'mongoose' | 'prisma' | 'drizzle' | 'typeorm' | 'custom';
167
+ /** Human-readable name. */
168
+ readonly name: string;
169
+ /**
170
+ * Generate OpenAPI-shaped schemas for CRUD operations. Each adapter
171
+ * produces schemas appropriate to its ORM/database (mongokit
172
+ * introspects Mongoose paths; sqlitekit introspects Drizzle columns).
173
+ *
174
+ * Options use repo-core's `SchemaBuilderOptions` floor — host-specific
175
+ * extensions (arc's `RouteSchemaOptions`) extend this base structurally.
176
+ *
177
+ * @param options - Schema generation options (field rules, populate hints).
178
+ * @param context - Resource-level context (`idField` for params shape,
179
+ * `name` for logs).
180
+ */
181
+ generateSchemas?(options?: SchemaBuilderOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
182
+ /** Extract schema metadata for OpenAPI / introspection. */
183
+ getSchemaMetadata?(): SchemaMetadata | null;
184
+ /** Validate data against schema before persistence. */
185
+ validate?(data: unknown): Promise<AdapterValidationResult> | AdapterValidationResult;
186
+ /** Health check for database connection. */
187
+ healthCheck?(): Promise<boolean>;
188
+ /**
189
+ * Custom filter matching for in-memory policy enforcement. Falls back
190
+ * to the host's built-in shallow matcher when omitted. Override for
191
+ * SQL adapters, non-Mongo operators, or kits that compile Filter IR.
192
+ */
193
+ matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
194
+ /** Close / cleanup resources. */
195
+ close?(): Promise<void>;
196
+ /**
197
+ * Optional: does the underlying schema declare a path with this name?
198
+ *
199
+ * Used by hosts (e.g. arc's `defineResource()`) to infer absent tenant
200
+ * fields — without this hook, hosts who forget `tenantField: false` on
201
+ * cross-tenant tables get queries silently filtered to zero results.
202
+ * Adapters that can introspect their schema implement it; ones that
203
+ * can't omit it (the host falls back to its default behaviour).
204
+ *
205
+ * Implementation guidance:
206
+ * - Mongoose: `Boolean(this.model.schema.paths[name])`.
207
+ * - Drizzle / SQL kits: check column metadata.
208
+ *
209
+ * @returns `true` if the schema declares the path, `false` if not,
210
+ * `undefined` if the adapter can't determine it (treated as
211
+ * "unknown" — same as omitting the method).
212
+ */
213
+ hasFieldPath?(name: string): boolean | undefined;
214
+ }
215
+ /**
216
+ * Adapter factory signature. A kit's `createXxxAdapter(config)` matches
217
+ * this shape — config is kit-specific so adapters can accept their own
218
+ * options (e.g. `{ model, schemaGenerator, ... }`).
219
+ */
220
+ type AdapterFactory<TDoc = unknown> = (config: unknown) => DataAdapter<TDoc>;
221
+ //#endregion
222
+ export { AdapterFactory, AdapterRepositoryInput, AdapterSchemaContext, AdapterValidationResult, DataAdapter, FieldMetadata, OpenApiSchemas, RelationMetadata, RepositoryLike, SchemaMetadata };
@@ -0,0 +1,22 @@
1
+ import { AdapterRepositoryInput, RepositoryLike } from "./types.mjs";
2
+
3
+ //#region src/adapter/widen.d.ts
4
+ /**
5
+ * Widen a permissive `AdapterRepositoryInput<TDoc>` to the strict
6
+ * `RepositoryLike<TDoc>` view used by host internals.
7
+ *
8
+ * Single-source cast — kit adapters call this once at their factory
9
+ * boundary; host code (arc, future arc-next) consumes the strict view
10
+ * everywhere else.
11
+ */
12
+ declare function asRepositoryLike<TDoc = unknown>(input: AdapterRepositoryInput<TDoc>): RepositoryLike<TDoc>;
13
+ /**
14
+ * Runtime guard: does `value` look like a `RepositoryLike<TDoc>`?
15
+ *
16
+ * Checks for the five required methods (`getAll`, `getById`, `create`,
17
+ * `update`, `delete`) plus the optional `idField`. Used by adapter
18
+ * factories to validate input before wrapping.
19
+ */
20
+ declare function isRepository<TDoc = unknown>(value: unknown): value is RepositoryLike<TDoc>;
21
+ //#endregion
22
+ export { asRepositoryLike, isRepository };
@@ -0,0 +1,26 @@
1
+ //#region src/adapter/widen.ts
2
+ /**
3
+ * Widen a permissive `AdapterRepositoryInput<TDoc>` to the strict
4
+ * `RepositoryLike<TDoc>` view used by host internals.
5
+ *
6
+ * Single-source cast — kit adapters call this once at their factory
7
+ * boundary; host code (arc, future arc-next) consumes the strict view
8
+ * everywhere else.
9
+ */
10
+ function asRepositoryLike(input) {
11
+ return input;
12
+ }
13
+ /**
14
+ * Runtime guard: does `value` look like a `RepositoryLike<TDoc>`?
15
+ *
16
+ * Checks for the five required methods (`getAll`, `getById`, `create`,
17
+ * `update`, `delete`) plus the optional `idField`. Used by adapter
18
+ * factories to validate input before wrapping.
19
+ */
20
+ function isRepository(value) {
21
+ if (!value || typeof value !== "object") return false;
22
+ const v = value;
23
+ return typeof v["getAll"] === "function" && typeof v["getById"] === "function" && typeof v["create"] === "function" && typeof v["update"] === "function" && typeof v["delete"] === "function";
24
+ }
25
+ //#endregion
26
+ export { asRepositoryLike, isRepository };
@@ -0,0 +1,3 @@
1
+ import { DecodedCursor, decodeAggCursor, encodeAggCursor, isKeysetMode } from "./keyset.mjs";
2
+ import { normalizeGroupBy, validateMeasures } from "./normalize.mjs";
3
+ export { type DecodedCursor, decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures };
@@ -0,0 +1,3 @@
1
+ import { decodeAggCursor, encodeAggCursor, isKeysetMode } from "./keyset.mjs";
2
+ import { normalizeGroupBy, validateMeasures } from "./normalize.mjs";
3
+ export { decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures };
@@ -0,0 +1,57 @@
1
+ //#region src/aggregate/keyset.d.ts
2
+ /**
3
+ * Keyset (cursor) pagination helpers — the portable, kit-neutral half.
4
+ *
5
+ * Cursor encode/decode is identical across kits: serialize the sort-key
6
+ * tuple of the last row, base64url it, hand back. Every kit's
7
+ * `aggregatePaginate(req)` produced byte-identical encodings before
8
+ * this module landed — the duplication served no purpose.
9
+ *
10
+ * What this module does NOT cover: building the kit-specific predicate
11
+ * that selects rows AFTER the cursor. Mongo emits a `$match` JSON
12
+ * stage; SQL emits a `HAVING (col1, col2) > (?, ?)` Drizzle SQL
13
+ * fragment. Those compilers stay in each kit because they speak the
14
+ * driver's query language. They consume `DecodedCursor` from here.
15
+ *
16
+ * **Cross-kit cursor compatibility is not promised.** The encoded
17
+ * cursor depends on which keys the kit's sort spec ends up using — a
18
+ * Mongo cursor with `_id` won't round-trip on a SQL kit using `id`.
19
+ * Consumers MUST round-trip cursors verbatim against the same backend
20
+ * that produced them.
21
+ */
22
+ /**
23
+ * Decoded cursor — sort-key → value tuples from the last row of the
24
+ * prior page. Values are JSON-serialisable scalars (numbers, strings,
25
+ * booleans, ISO date strings). `undefined` and `null` collapse to
26
+ * `null` on encode so the round-trip is stable.
27
+ */
28
+ type DecodedCursor = Record<string, unknown>;
29
+ /**
30
+ * Encode a cursor from the last row of a page given the sort spec.
31
+ *
32
+ * Only sort keys are extracted — the cursor MUST be small (it travels
33
+ * over the URL on every "next page" request). Carrying the full row
34
+ * would inflate cursors with measure values and group keys that
35
+ * aren't load-bearing for pagination.
36
+ */
37
+ declare function encodeAggCursor(row: Record<string, unknown>, sort: Record<string, 1 | -1>): string;
38
+ /**
39
+ * Decode a cursor previously produced by `encodeAggCursor`. Throws on
40
+ * any malformed cursor — callers should treat the throw as "client
41
+ * sent garbage" and surface a 400-class error rather than masking it.
42
+ *
43
+ * The `kitName` prefix on the error message keeps stack-trace context
44
+ * legible (`'mongokit/aggregate: ...'` vs `'sqlitekit/aggregate: ...'`).
45
+ */
46
+ declare function decodeAggCursor(cursor: string, kitName: string): DecodedCursor;
47
+ /**
48
+ * Pick the keyset mode flag from the request shape. `pagination:
49
+ * 'keyset'` is the explicit form; setting `after` implies keyset
50
+ * (handing back a cursor token in offset mode would be a wiring bug).
51
+ */
52
+ declare function isKeysetMode(req: {
53
+ pagination?: string;
54
+ after?: string;
55
+ }): boolean;
56
+ //#endregion
57
+ export { DecodedCursor, decodeAggCursor, encodeAggCursor, isKeysetMode };
@@ -0,0 +1,45 @@
1
+ //#region src/aggregate/keyset.ts
2
+ /**
3
+ * Encode a cursor from the last row of a page given the sort spec.
4
+ *
5
+ * Only sort keys are extracted — the cursor MUST be small (it travels
6
+ * over the URL on every "next page" request). Carrying the full row
7
+ * would inflate cursors with measure values and group keys that
8
+ * aren't load-bearing for pagination.
9
+ */
10
+ function encodeAggCursor(row, sort) {
11
+ const tuple = {};
12
+ for (const key of Object.keys(sort)) tuple[key] = row[key];
13
+ return Buffer.from(JSON.stringify(tuple), "utf8").toString("base64url");
14
+ }
15
+ /**
16
+ * Decode a cursor previously produced by `encodeAggCursor`. Throws on
17
+ * any malformed cursor — callers should treat the throw as "client
18
+ * sent garbage" and surface a 400-class error rather than masking it.
19
+ *
20
+ * The `kitName` prefix on the error message keeps stack-trace context
21
+ * legible (`'mongokit/aggregate: ...'` vs `'sqlitekit/aggregate: ...'`).
22
+ */
23
+ function decodeAggCursor(cursor, kitName) {
24
+ let parsed;
25
+ try {
26
+ const json = Buffer.from(cursor, "base64url").toString("utf8");
27
+ parsed = JSON.parse(json);
28
+ } catch (cause) {
29
+ throw new Error(`${kitName}/aggregate: malformed keyset cursor — base64url+JSON decode failed (${cause.message})`);
30
+ }
31
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${kitName}/aggregate: malformed keyset cursor — expected an object payload`);
32
+ return parsed;
33
+ }
34
+ /**
35
+ * Pick the keyset mode flag from the request shape. `pagination:
36
+ * 'keyset'` is the explicit form; setting `after` implies keyset
37
+ * (handing back a cursor token in offset mode would be a wiring bug).
38
+ */
39
+ function isKeysetMode(req) {
40
+ if (req.pagination === "keyset") return true;
41
+ if (typeof req.after === "string" && req.after.length > 0) return true;
42
+ return false;
43
+ }
44
+ //#endregion
45
+ export { decodeAggCursor, encodeAggCursor, isKeysetMode };