@classytic/repo-core 0.2.0 → 0.4.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 +363 -0
- package/README.md +28 -7
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +4 -2
- package/dist/errors/index.mjs +4 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +26 -0
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +57 -3
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +60 -9
- package/dist/schema/field-rules.mjs +121 -10
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +3 -2
- package/dist/schema/index.mjs +3 -2
- package/dist/schema/types.d.mts +77 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +27 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,369 @@ 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
|
+
|
|
250
|
+
## [0.3.0] - 2026-04-29
|
|
251
|
+
|
|
252
|
+
### Added — Aggregate pagination shapes
|
|
253
|
+
|
|
254
|
+
- `AggregatePaginationResultCore<TDoc>` and `AggregatePaginationResult<TDoc, TExtra>` join `Offset*` / `Keyset*` as the third pagination shape every kit reports. Mirrors offset (page / total / pages / hasNext / hasPrev) with `method: 'aggregate'` discriminant. Mongokit's existing local `AggregatePaginationResult` (3.10.x) becomes redundant — to be deleted in mongokit 4.0.
|
|
255
|
+
- `AnyPaginationResult<TDoc, TExtra>` — union over the three result shapes. Use as the input type to anything that converts repo results into HTTP envelopes.
|
|
256
|
+
|
|
257
|
+
### Added — HTTP wire envelopes
|
|
258
|
+
|
|
259
|
+
The repository result shapes (`OffsetPaginationResult`, etc.) carry the `method` discriminant, so the corresponding HTTP wire envelope is just `{ success: true } & Result`. Adding the literal here closes the **server/client envelope mismatch** — arc's HTTP server was emitting flattened paginated responses without the `method` field while arc-next's typed responses required it.
|
|
260
|
+
|
|
261
|
+
- `OffsetPaginationResponse<TDoc, TExtra>` = `{ success: true } & OffsetPaginationResult<TDoc, TExtra>`
|
|
262
|
+
- `KeysetPaginationResponse<TDoc, TExtra>` = same for keyset
|
|
263
|
+
- `AggregatePaginationResponse<TDoc, TExtra>` = same for aggregate
|
|
264
|
+
- `BareListResponse<TDoc>` = `{ success: true; docs: TDoc[] }` for endpoints that don't paginate
|
|
265
|
+
- `PaginatedResponse<TDoc, TExtra>` = union over all four. The discriminated-union contract is `success: true` literal first, `method` second — typed clients (arc-next, SDKs) narrow with `if (res.success && 'method' in res && res.method === 'offset')`.
|
|
266
|
+
|
|
267
|
+
### Added — `toCanonicalList()` runtime normalizer
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { toCanonicalList } from '@classytic/repo-core/pagination';
|
|
271
|
+
|
|
272
|
+
const result = await userRepo.getAll(query);
|
|
273
|
+
reply.send(toCanonicalList(result)); // → PaginatedResponse<User>
|
|
274
|
+
|
|
275
|
+
reply.send(toCanonicalList([u1, u2])); // → BareListResponse<User>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The single point where an internal `Result` becomes an external `Response`. Three overloads route bare arrays / paginated results to the right wire shape; `TExtra` fields (mongokit's `warning?: string`, etc.) flow through.
|
|
279
|
+
|
|
280
|
+
**Subtle behavior**: `success: true` is stamped *after* the spread, so a stale `success: false` accidentally present on the input cannot override the literal — paginated success path is always `success: true`. Tested.
|
|
281
|
+
|
|
282
|
+
### Added — `isPaginatedResult()` type guard
|
|
283
|
+
|
|
284
|
+
Branches on the `method` discriminant rather than `Array.isArray`, so an empty paginated result still routes through the paginated branch. Used internally by `toCanonicalList`; exported for consumers writing custom envelope logic.
|
|
285
|
+
|
|
286
|
+
### Test delta
|
|
287
|
+
|
|
288
|
+
230 → 303 tests across 0.3.0. New coverage includes `tests/unit/pagination/canonical.test.ts` and the type-level coverage extensions in `result-types.test.ts` (35 tests landed with the aggregate / wire-envelope / `toCanonicalList` work), plus `tests/unit/repository/base-plugin-validation.test.ts` (6 tests for the `assertValidPlugin` guard).
|
|
289
|
+
|
|
290
|
+
### Added — `SchemaGenerator<TModel>` interface in `/schema`
|
|
291
|
+
|
|
292
|
+
Canonical contract for repository kits' CRUD-schema generators. Mongokit's `buildCrudSchemasFromModel` and sqlitekit's `buildCrudSchemasFromTable` (and any future kit's equivalent) `satisfies SchemaGenerator<TKitModel>` at the call site, so arc's `MongooseAdapter.schemaGenerator` / `DrizzleAdapter.schemaGenerator` accept them by structural typing — no glue, no inheritance, no inline function signatures duplicated in every adapter.
|
|
293
|
+
|
|
294
|
+
- `SchemaGenerator<TModel = unknown>` — `(model, options?, context?) => CrudSchemas | Record<string, unknown>`.
|
|
295
|
+
- `SchemaGeneratorContext` — resource-level context threaded at boot (`idField`, `resourceName`).
|
|
296
|
+
- `isSchemaGenerator(value)` — runtime predicate (arity 1-3 functions). Conservative — doesn't invoke.
|
|
297
|
+
|
|
298
|
+
Each kit ships a compile-time conformance check (same playbook as mongokit's `RepositoryLike` conformance gate):
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
const _conformance: SchemaGenerator<Model<unknown>> = buildCrudSchemasFromModel;
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Drift surfaces in the kit's typecheck immediately, before any consumer sees it.
|
|
305
|
+
|
|
306
|
+
10 new tests in `tests/unit/schema/generator.test.ts`. Total repo-core: 293 → 303.
|
|
307
|
+
|
|
308
|
+
### Added — `errors` module: canonical wire + throwable error contract
|
|
309
|
+
|
|
310
|
+
`@classytic/repo-core/errors` is now the single source of truth for error contracts across the org. Two complementary shapes:
|
|
311
|
+
|
|
312
|
+
- **`HttpError extends Error`** — the *throwable* shape. Plain `Error` with `status`, optional `code`, `meta`, `validationErrors`, `duplicate`. Kits classify their driver-specific errors into this shape; framework layers (arc) catch and serialize. Existing `HttpError` extended in 0.3 with `code?: string` and `meta?: Record<string, unknown>` (mongokit had these locally pre-3.12).
|
|
313
|
+
- **`ErrorContract`** — the *wire* shape (RFC 7807 / Stripe-style). What gets serialized to JSON responses, dead-letter records, audit trails, inter-service envelopes. Flat top-level `code` / `message` / `status` matches the org-wide `{ success, ... }` envelope convention.
|
|
314
|
+
- **`ErrorDetail`** — single field-scoped error (path / code / message). `ErrorContract.details` is `ReadonlyArray<ErrorDetail>`.
|
|
315
|
+
- **`ERROR_CODES` + `ErrorCode`** — canonical lowercase + snake_case codes (`'validation_error'`, `'not_found'`, `'conflict'`, `'unauthorized'`, `'forbidden'`, `'rate_limited'`, `'idempotency_conflict'`, `'precondition_failed'`, `'internal_error'`, `'service_unavailable'`, `'timeout'`). Domain packages extend hierarchically (`'order.validation.missing_line'`).
|
|
316
|
+
- **`toErrorContract(error)`** — converts any `Error` / `HttpError` / non-`Error` value to the canonical wire `ErrorContract`. `code` cascade: explicit `error.code` → status-derived → `'internal_error'`. Flattens mongokit-shaped `validationErrors` and `duplicate.fields` into the canonical `details[]` array.
|
|
317
|
+
- **`statusToErrorCode(status)`** — well-known HTTP status → canonical code. Conservative mapping; unknown statuses fall through to `'internal_error'` so domain handlers explicitly opt in.
|
|
318
|
+
|
|
319
|
+
Consumed by mongokit (drops local `HttpError`), arc (`ArcError implements HttpError` with `status` getter), and any future kit / service. Relocated from `@classytic/primitives/errors` (which had `ErrorContract` + `ERROR_CODES` but not the throwable contract) — same playbook as the pagination, tenant, and events relocations: errors are infrastructure-shaped, not domain primitives.
|
|
320
|
+
|
|
321
|
+
14 new tests in `tests/unit/errors/contract.test.ts`. Total repo-core: 279 → 293.
|
|
322
|
+
|
|
323
|
+
### Added — `tenant` subpath (canonical home for tenant scope contract)
|
|
324
|
+
|
|
325
|
+
New subpath `@classytic/repo-core/tenant` ships:
|
|
326
|
+
- `TenantConfig` — static config (`strategy`, `enabled`, `tenantField`, `fieldType`, `ref`, `contextKey`, `required`, `resolve`).
|
|
327
|
+
- `TenantStrategy = 'field' | 'none' | 'custom'`, `TenantFieldType = 'objectId' | 'string'`.
|
|
328
|
+
- `ResolvedTenantConfig` — the resolved-with-defaults shape returned by `resolveTenantConfig`.
|
|
329
|
+
- `DEFAULT_TENANT_CONFIG` — sensible org-wide defaults (`tenantField: 'organizationId'`, `fieldType: 'objectId'`, `ref: 'organization'`, `required: true`).
|
|
330
|
+
- `resolveTenantConfig(config?)` — normaliser; validates `'custom'` strategy requires `resolve`.
|
|
331
|
+
|
|
332
|
+
Relocated from `@classytic/primitives/tenant` (which has been removed in primitives 0.3 cleanup). Tenant scope is **infrastructure-shaped** — describes how queries get scoped, not a domain primitive like Money or Address. Repo-core is its proper home: it sits next to `context`, `filter`, `hooks`, `schema`, `cache` — every other repository contract — and lets mongokit / sqlitekit / future kits consume it through the existing `@classytic/repo-core` peer dep without pulling primitives just for one type.
|
|
333
|
+
|
|
334
|
+
**Custom tenancy escape hatch** unchanged: `strategy: 'custom'` + `resolve: (ctx) => filterShape` covers multi-field composites, region+partner shards, hash-derived filters, anything that doesn't fit `field === id`.
|
|
335
|
+
|
|
336
|
+
14 new tests in `tests/unit/tenant/resolve.test.ts` (ported from primitives' suite). Total repo-core: 265 → 279 tests.
|
|
337
|
+
|
|
338
|
+
### Added — schema-builder vocabulary
|
|
339
|
+
|
|
340
|
+
- **`SchemaBuilderOptions.excludeFields`** — global field exclusion. Fields listed here are dropped from create / update / response schemas in one place. Equivalent to setting `create.omitFields`, `update.omitFields`, AND `response.omitFields` to the same list. Use for fields that should never appear in any HTTP-facing schema.
|
|
341
|
+
- **`SchemaBuilderOptions.response`** with `omitFields?: string[]` — response-schema overrides. Drops extra fields from the response shape without marking them globally hidden.
|
|
342
|
+
- **`CrudSchemas.response?: JsonSchema`** — optional response-shape schema. Includes server-set fields (`createdAt`, `updatedAt`, `_id`, immutable / readonly / systemManaged) since those ARE returned to clients. Only `fieldRules[field].hidden: true` strips automatically. Set `additionalProperties: true` so virtuals / computed fields pass through.
|
|
343
|
+
- **`FieldRule.hidden?: boolean`** — strips the field from the response shape. Distinct from `systemManaged` (request-body concern). Use for passwords, secrets, internal scoring.
|
|
344
|
+
- **`collectFieldsToOmit(options, 'response')`** — third purpose alongside `'create'` / `'update'`. Implements the response policy (only `hidden` + `excludeFields` + `response.omitFields`).
|
|
345
|
+
|
|
346
|
+
These are the contracts mongokit 3.12 implements, arc 2.12's MongooseAdapter consumes, and any future kit (sqlitekit, prismakit) inherits for free.
|
|
347
|
+
|
|
348
|
+
### Hardened — `RepositoryBase` plugin-shape validation
|
|
349
|
+
|
|
350
|
+
`RepositoryBase.use()` and the constructor's plugin loop now reject malformed plugin entries up front via `assertValidPlugin()`. The motivating field bug: `new Repository(Model, ['organizationId'], opts)` — passing a tenant-field string array where the constructor expected `plugins[]` — used to crash deep in the call site with `TypeError: plugin.apply is not a function`, cascade-failing every test that booted the app. The validator now throws a single descriptive `TypeError` at construction with the offending index and a hint about the common `tenantField`-in-the-wrong-slot mistake:
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
[repo-core] Repository "Foo": plugin at index 0 has wrong type.
|
|
354
|
+
Expected a function or { name, apply(repo) } object — got string 'organizationId'.
|
|
355
|
+
Common cause: `new Repository(Model, [tenantField], opts)` — second argument must be a plugins array.
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Lock-in: `tests/unit/repository/base-plugin-validation.test.ts` (6 cases covering string/null/object-without-apply/function/object/post-construction `use()` paths).
|
|
359
|
+
|
|
360
|
+
### Migration — mongokit 4.0, arc 2.12, arc-next 0.6
|
|
361
|
+
|
|
362
|
+
Three downstream changes drop their local copies and import directly:
|
|
363
|
+
|
|
364
|
+
1. **mongokit 4.0** — deletes its local `AggregatePaginationResult` declaration and `PaginationResult` union; consumers that imported them from `@classytic/mongokit` must switch to `@classytic/repo-core/pagination`. (Breaking.)
|
|
365
|
+
2. **arc 2.12** — `fastifyAdapter` calls `toCanonicalList()` once instead of inline-flattening offset and falling through keyset/aggregate as nested `data`. Closes a real wire-envelope-mismatch bug.
|
|
366
|
+
3. **arc-next 0.6** — adds `@classytic/repo-core` as peer dep, deletes its local `OffsetPaginationResponse` / `KeysetPaginationResponse` / `AggregatePaginationResponse` / `PaginatedResponse` types. Server and client now share one declaration — the `method` field asymmetry is impossible by construction.
|
|
367
|
+
|
|
368
|
+
No breaking changes inside `@classytic/repo-core` itself — purely additive.
|
|
369
|
+
|
|
7
370
|
## [0.2.0] - 2026-04-22
|
|
8
371
|
|
|
9
372
|
### Added — Update IR (portable write-side primitive)
|
package/README.md
CHANGED
|
@@ -28,14 +28,34 @@ import { and, eq, gte, in_, like, buildTenantScope, matchFilter } from '@classyt
|
|
|
28
28
|
// URL → ParsedQuery grammar. Backend frameworks (Express/Arc/Fastify) parse req.query here.
|
|
29
29
|
import { parseUrl } from '@classytic/repo-core/query-parser';
|
|
30
30
|
|
|
31
|
-
// Pagination primitives — cursor codec, keyset helpers, offset math
|
|
32
|
-
|
|
31
|
+
// Pagination primitives — cursor codec, keyset helpers, offset math, the canonical
|
|
32
|
+
// result types (`OffsetPaginationResult`, `KeysetPaginationResult`,
|
|
33
|
+
// `AggregatePaginationResult`, `PaginationResult`) and the wire helper
|
|
34
|
+
// `toCanonicalList()`. Single source of truth — primitives' duplicate dropped,
|
|
35
|
+
// mongokit/sqlitekit re-export from here.
|
|
36
|
+
import { encodeCursor, decodeCursor, validateKeysetSort, toCanonicalList } from '@classytic/repo-core/pagination';
|
|
37
|
+
import type { OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult, PaginationResult } from '@classytic/repo-core/pagination';
|
|
38
|
+
|
|
39
|
+
// Tenant config — the canonical `TenantConfig`, `TenantStrategy`, `TenantFieldType`,
|
|
40
|
+
// `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`. Kits'
|
|
41
|
+
// `MultiTenantOptions extends Pick<TenantConfig, ...>`.
|
|
42
|
+
import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
|
|
43
|
+
import type { TenantConfig, ResolvedTenantConfig } from '@classytic/repo-core/tenant';
|
|
33
44
|
|
|
34
45
|
// Cache plumbing — the CacheAdapter interface every kit's cachePlugin writes against.
|
|
35
46
|
import { type CacheAdapter, stableStringify, createMemoryCacheAdapter } from '@classytic/repo-core/cache';
|
|
36
47
|
|
|
37
|
-
//
|
|
38
|
-
|
|
48
|
+
// Error contracts — `HttpError` throwable + `ErrorContract` wire shape +
|
|
49
|
+
// `ErrorDetail` + `ErrorCode` + `ERROR_CODES` + `toErrorContract()` +
|
|
50
|
+
// `statusToErrorCode()`. Single source of truth — primitives' errors module dropped,
|
|
51
|
+
// mongokit's local `HttpError` dropped, `ArcError implements HttpError`.
|
|
52
|
+
import { toErrorContract, statusToErrorCode, ERROR_CODES, createError, conservativeMongoIsDuplicateKey } from '@classytic/repo-core/errors';
|
|
53
|
+
import type { HttpError, ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
|
|
54
|
+
|
|
55
|
+
// Schema generator interface — kits ship `SchemaGenerator<TModel>` + the
|
|
56
|
+
// compile-time conformance assertion; arc adapters are typed against it.
|
|
57
|
+
import type { SchemaGenerator, SchemaGeneratorContext } from '@classytic/repo-core/schema';
|
|
58
|
+
import { isSchemaGenerator } from '@classytic/repo-core/schema';
|
|
39
59
|
|
|
40
60
|
// Operation registry (for arc-level policy dispatch + doc generation).
|
|
41
61
|
import { CORE_OP_REGISTRY, describe } from '@classytic/repo-core/operations';
|
|
@@ -141,11 +161,12 @@ Default `TExtra` is `Record<string, never>` — `OffsetPaginationResult<User>` b
|
|
|
141
161
|
|
|
142
162
|
## Status
|
|
143
163
|
|
|
144
|
-
**v0.
|
|
164
|
+
**v0.3.0 — canonical contracts release.** Pagination types + wire envelope, tenant config, error contracts, and the `SchemaGenerator<TModel>` interface relocated from primitives / mongokit / arc to single sources of truth here.
|
|
145
165
|
|
|
146
166
|
Consumed by:
|
|
147
|
-
- `@classytic/mongokit` ≥ 3.
|
|
148
|
-
- `@classytic/sqlitekit`
|
|
167
|
+
- `@classytic/mongokit` ≥ 3.12 — `Repository extends RepositoryBase`; hook engine, plugin-order validator, `HOOK_PRIORITY` sourced from repo-core. Pagination + `HttpError` types now flow from repo-core (mongokit's local copies dropped). `MultiTenantOptions extends Pick<TenantConfig, ...>`. `buildCrudSchemasFromModel` ships a compile-time `SchemaGenerator<TModel>` conformance assertion. Mongokit's own `QueryParser` remains standalone.
|
|
168
|
+
- `@classytic/sqlitekit` ≥ 0.2 — `SqliteRepository extends RepositoryBase`; Filter IR compiled to Drizzle / raw SQL natively. `MultiTenantOptions extends Pick<TenantConfig, ...>`. `buildCrudSchemasFromTable` ships the same `SchemaGenerator` conformance assertion.
|
|
169
|
+
- `@classytic/arc` ≥ 2.12 — adapters typed against `SchemaGenerator<TModel>`; `ArcError implements HttpError`; pagination wire envelope (`method` discriminant) emitted via `toCanonicalList()` with `reply.sendList()`.
|
|
149
170
|
|
|
150
171
|
See [INFRA.md](./INFRA.md) for the architectural principles, subpath map, build/tooling decisions, and the roadmap for pgkit / prismakit.
|
|
151
172
|
|
|
@@ -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 };
|