@classytic/repo-core 0.28.0 → 0.30.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 (55) hide show
  1. package/CHANGELOG.md +1290 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/sync/index.d.mts +90 -55
  43. package/dist/sync/index.mjs +0 -0
  44. package/dist/testing/change-log-conformance.d.mts +21 -0
  45. package/dist/testing/change-log-conformance.mjs +204 -0
  46. package/dist/testing/command-stream-conformance.d.mts +15 -0
  47. package/dist/testing/command-stream-conformance.mjs +163 -0
  48. package/dist/testing/idempotency-conformance.d.mts +15 -0
  49. package/dist/testing/idempotency-conformance.mjs +315 -0
  50. package/dist/testing/index.d.mts +5 -1
  51. package/dist/testing/index.mjs +5 -1
  52. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  53. package/dist/testing/query-grammar-conformance.mjs +380 -0
  54. package/package.json +6 -2
  55. package/dist/errors/types.mjs +0 -29
package/CHANGELOG.md CHANGED
@@ -1,1098 +1,1290 @@
1
- # Changelog
2
-
3
- All notable changes to `@classytic/repo-core` are documented here.
4
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [0.28.0] - 2026-09-14
8
-
9
- ### Changed — BREAKING: `RetryPolicy.shouldRetry` no longer defaults to retrying everything
10
-
11
- `shouldRetry` defaulted to `() => true`, with a comment conceding it was unsafe
12
- and asking callers to pass their own. That is a documented hazard, not a fence:
13
- a policy added for network blips also re-ran the write on a duplicate key, a
14
- validation failure and a permission denial — three times, with backoff. Against
15
- a slow-but-succeeding server and a non-idempotent write, that is a double write.
16
-
17
- The default is now **`isTransientError`** (exported from `./repository`): retry
18
- a failure that MAY not have happened — failover, write conflict, lock timeout,
19
- socket error — never one the server answered deterministically. A 4xx-shaped
20
- `statusCode` is treated as final, since `HttpError` carries one and every kit
21
- maps validation and permission failures onto it.
22
-
23
- **Migration:** a caller relying on retry-everything passes `shouldRetry: () => true`
24
- explicitly. The point is that it is now a decision rather than an inheritance.
25
- Tests exercising retry MECHANICS should pass a predicate too, so they stop
26
- depending on how a generic `Error` happens to be classified.
27
-
28
- ### Added
29
-
30
- - **`resumeToken` on `ChangeEvent`, `startAfter` on `WatchOptions`.** `watch()`
31
- already accepted a resume token but a consumer had no way to obtain one, so
32
- the documented at-least-once-across-restarts contract was unfulfillable.
33
- Persist the last event's `resumeToken` and hand it back as `resumeAfter` (or
34
- `startAfter`, which also resumes past an invalidate event) to continue from
35
- that point. Additive — a kit whose feed is not resumable leaves the field
36
- absent.
37
-
38
- - **`AggRequest.countDistinctStrategy`** — `'accumulator'` (default) or
39
- `'grouped'`. The default holds every distinct value of a group in that
40
- group's accumulator, so memory grows with the data and MongoDB caps it at
41
- 100MB; `'grouped'` pre-groups by the distinct field so each value becomes a
42
- row instead, and nothing accumulates. Results are identical — including that
43
- a missing field is not a distinct value while an explicit `null` is. Kits
44
- refuse the measure combinations they cannot recombine (`avg`, `percentile`,
45
- `stddev`, a second `countDistinct`) rather than approximating them.
46
-
47
- - **`prev` / `hasPrev` on `KeysetPaginationResultCore`** — the backward half of
48
- cursor pagination, for kits that implement a `before` cursor (mongokit does,
49
- >= 3.39.0). Both are OPTIONAL, so a kit that only pages forward is unchanged.
50
-
51
- `prev === undefined` means *"this kit does not answer the question"*, NOT
52
- *"there is no previous page"*. A UI must read `hasPrev` to decide whether to
53
- offer the control and treat absent as unknown — reading `undefined` as `false`
54
- hides a control that should be there.
55
-
56
- ### Changed
57
-
58
- - **`validateKeysetSort` accepts mixed sort directions.** `{ priority: 1,
59
- createdAt: -1 }` — the ESR-shaped compound index — used to throw. The keyset
60
- predicate is a tuple comparison whose operator is chosen per position, so the
61
- directions never needed to agree; rejecting them pushed callers onto offset
62
- `skip(n)`, the cost keyset exists to avoid. `_id` now keeps the direction the
63
- caller gave it, and still follows the primary field when absent.
64
-
65
- **A kit's filter builder must read the direction PER FIELD** to consume this
66
- safely. mongokit's `buildKeysetFilter` does (>= 3.39.0). A kit that derives
67
- one operator from the primary field will now silently mis-paginate a
68
- mixed-direction sort instead of being protected by the throw — audit yours
69
- before upgrading.
70
-
71
- ### Fixed
72
-
73
- - **A cached `getAll` page was served to a caller that asked for a different
74
- count strategy.** `countStrategy` / `countLimit` were missing from
75
- `DEFAULT_SHAPE_KEYS_BY_OP.getAll`, so every strategy shared one cache key —
76
- and the strategies disagree about `total` BY DESIGN: `'none'` reports `0`,
77
- `'capped'` reports a flagged ceiling, `'exact'` reports the truth. A list
78
- asking for an exact count could therefore render `0 results` over a full page
79
- of rows, with nothing reporting an error. Both keys are now on the allowlist.
80
-
81
- `mode` remains off it deliberately — it selects which ENVELOPE comes back, and
82
- offset vs keyset are already separated by `page` vs `after`.
83
-
84
- **On deploy this invalidates existing `getAll` cache entries once** (the key
85
- shape changed); the next read repopulates them.
86
-
87
- Pinned by mongokit's `plugin-composition-security.test.ts`, whose
88
- `countStrategy` case had been `it.skip`ped with a comment documenting the hole
89
- rather than closing it.
90
-
91
- ## [0.27.1] - 2026-09-11
92
-
93
- ### Fixed
94
-
95
- - **A failed single-flight fetch with no concurrent waiter raised an
96
- `unhandledRejection`** — in addition to the error the caller had already
97
- caught. `claimPending` creates a deferred for waiters that may never arrive;
98
- rejecting it for nobody made a working `try/catch` a process crash under
99
- `--unhandled-rejections=strict`. Reached from `CacheEngine.prefetch`, so from
100
- every `createTtlMemo` consumer, and from the plugin read path. The deferred is
101
- now marked observed at creation; a waiter that does exist still receives the
102
- rejection. `tests/unit/cache/single-flight-unobserved-rejection.test.ts` pins
103
- the single-caller case the existing two-caller test could never reach.
104
-
105
- ## [0.27.0] - 2026-09-09
106
-
107
- ### Added
108
-
109
- - **`createTtlMemo(load, { ttlMs, keyOf?, maxEntries? })`** on `./cache` — the
110
- one way to memoise a keyed async load for a while. It is `CacheEngine.prefetch`
111
- over the memory adapter, so concurrent misses for one key share ONE load, a
112
- thrown load caches nothing, and a `null` result is an answer.
113
-
114
- Four packages had each hand-rolled `Map<key, { at, value }>` with a TTL, and
115
- every one wrote its entry AFTER the await. Under parallel event dispatch N
116
- callers reach the await together, all miss, and the cache only ever helps the
117
- caller after them — a 30 s TTL that measured nine reads of one row per event.
118
- `tests/unit/cache/ttl-memo.test.ts` pins single flight, expiry, invalidation,
119
- `ttlMs: 0` = off, and refuses an object key without `keyOf` (an
120
- identity-compared key never hits and reads as a caching bug).
121
-
122
- ## [0.26.1] - 2026-09-08
123
-
124
- ### Fixed
125
-
126
- - **`vitest` is no longer declared as a peer dependency.** It was
127
- `peerDependencies: { vitest: "^3.0.0 || ^4.0.0" }` with
128
- `peerDependenciesMeta.optional: true` — and that combination does not do what
129
- it looks like. `optional: true` only silences the *missing* case; npm still
130
- hard-fails `ERESOLVE` on a version **mismatch**. So every consumer on a newer
131
- vitest could not install repo-core at all, even though nothing they imported
132
- touched it.
133
-
134
- Measured: `@classytic/arc` could not move to vitest 5 — `npm install` failed
135
- with `Conflicting peer dependency: vitest@5.0.0`, sourced to this package.
136
- Widening the range to `|| ^5.0.0` would only have moved the wall to vitest 6.
137
-
138
- `vitest` is the CONSUMER's test runner. Anyone importing
139
- `@classytic/repo-core/testing` is by definition already inside a vitest suite
140
- that provides it, so the import resolves from their install; anyone who does
141
- not import it should never have been constrained. It stays in
142
- `devDependencies` for this package's own suite, and `tsdown`'s `neverBundle`
143
- now lists `vitest` so the bundler keeps the import external instead of
144
- inlining a dev-only dependency into shipped output.
145
-
146
- Any vitest exposing the standard `describe` / `it` / `expect` / `beforeEach` /
147
- `afterEach` API works with the `/testing` conformance suites.
148
-
149
- ## [0.26.0] - 2026-09-02
150
-
151
- ### Added
152
-
153
- - **`@classytic/repo-core/loader` — `createBatchLoader`.** Read-side counterpart
154
- of a kit's `bulkWrite`: keys issued anywhere in one tick coalesce into a single
155
- `batch()` call, so a per-item read in a loop costs one round trip instead of N.
156
- Create one per operation — it caches, so its lifetime is a correctness
157
- property and the caller owns it.
158
-
159
- Refuses rather than guesses, in three places that otherwise return a plausible
160
- wrong answer with nothing reported:
161
- - a `batch()` result whose length differs from the keys (a short array shifts
162
- every later value onto the wrong key, and each value is individually valid);
163
- - an object key with no `keyOf` (`String({})` is `[object Object]` for every
164
- object, so the default would merge unrelated keys AND report the collisions
165
- as cache hits);
166
- - a non-integer or `< 1` `maxBatchSize` (`Math.max(1, NaN)` is `NaN`, and a
167
- `NaN` stride abandons every waiter with no error) — same validation
168
- `repository/purge.ts` already applies to this parameter.
169
-
170
- Known limits: chunks beyond `maxBatchSize` run concurrently with no cap, and a
171
- sequential `for … await loader.load(x)` loop batches nothing by construction
172
- (each `await` closes the microtask window). Use `loadMany` or `Promise.all`,
173
- and read `stats.batches` to confirm the round-trip count you expected.
174
-
175
- ## [0.25.0] - 2026-08-24
176
-
177
- ### Added
178
-
179
- - **`CacheAdapterResolver = () => CacheAdapter | undefined`** — a function consulted per call that returns the store for the current scope, or `undefined` for "no cache right now". `undefined` is inert — identical to `enabled: false` — and never falls back to a shared store, because that fallback is the cross-scope leak the scoping exists to prevent.
180
- - **`CacheAdapterSource = CacheAdapter | CacheAdapterResolver`** — `CacheEngine` and `cachePlugin` now accept either a fixed process-lifetime adapter (unchanged behaviour) or a resolver. A bare adapter is sugar for `() => adapter`; normalised at construction so every call site is on one path.
181
- - **`CacheAdapterResolver` and `CacheAdapterSource` exported from `@classytic/repo-core/cache`** and re-exported from the plugin index.
182
-
183
- ## [0.24.0] - 2026-08-17
184
-
185
- Version bump only — republished 0.23.1's contents under a minor so consumers pinning `>=0.24.0` get the tenant guard by a floor rather than a patch. No source changes over 0.23.1.
186
-
187
- ## [0.23.1] - 2026-08-17
188
-
189
- ### Added — `assertNoLegacyTenantKeys(config, pkg, extra?)` (`/tenant`)
190
-
191
- Refuses a config still carrying a pre-consolidation tenant key (`multiTenant`, plus any package-specific retirees passed via `extra`) instead of ignoring it.
192
-
193
- The rename onto `TenantConfig` under `tenant` is trivial; the failure mode when a CALLER misses it is not. Every kernel resolver reads `resolveTenantConfig(config.tenant ?? false)`, so an absent `tenant` resolves to `strategy: 'none'` — no tenant field, no tenant filter, every read spanning ALL tenants, no error, and figures that look plausible. A host that asked for tenancy silently gets none.
194
-
195
- The guard lives beside the resolver every one of those packages already calls, rather than being hand-rolled nine times — nine chances to forget, and the one that forgets ships the leak. Additive and non-breaking: nothing calls it until a package renames.
196
-
197
- ## [0.23.0] - 2026-08-13
198
-
199
- ### Added — transactional-core contracts (Phase 1a + 1d) + fencing (Phase 2, first slice)
200
-
201
- - **Concurrency-conflict taxonomy** (`/errors`): `IsTransientConflictFn` + `neverTransient` default + `conservativeMongoIsTransientConflict` — same ownership rule as `isDuplicateKeyError` (the kit knows its driver; repositories expose `isTransientConflictError`). The default is NO retry: re-running side effects on an unclassified failure is the unsafe direction.
202
- - **`VersionConflictError`** (`/errors`) + `isVersionConflictError` (survives duplicated copies): the optimistic-CAS violation, 409-shaped, distinct from not-found by contract — a stale version THROWS, never returns `null`, because collapsing the two invites a blind retry that clobbers the concurrent write.
203
- - **`WriteOptions.ifVersion`** + `RepoCapabilities.optimisticConcurrency`: CAS write contract — apply only at the expected version, increment on success, throw on the option when the capability is absent (a silently dropped guard is the defect, not a degraded mode).
204
- - **`retryingTransaction(repo, fn, opts)`** (`/repository`): the transactional retry envelope — `withTransaction` re-run on TRANSIENT conflicts only, bounded attempts, full-jitter backoff, abort-aware, classification from the kit predicate. Refuses (throws) a repository without `withTransaction` — no silent degrade. Contains no backoff math of its own: rides `withRetry`.
205
- - **`withRetry` gains `maxDelayMs` + `jitter` + an abortable sleep** — an aborted caller no longer waits out its backoff (mutation-proven: a plain `setTimeout` there times the pinning test out). Defaults unchanged.
206
- - **`TransactionHandle`**: `withTransaction`'s callback gains a second argument `{ session? }` — the raw driver handle, so work OUTSIDE the repository (the outbox writer, canonically) can join the same transaction. Kits MUST pass a handle object (possibly empty, for connection-bound backends); a conformance case pins the argument's presence. `retryingTransaction` forwards it structurally.
207
- - **`LockAdapter.tryAcquireFenced?`** (additive): a successful acquire returns a MONOTONIC token minted by the STORE — a process cannot fence itself. Token increments per ownership CHANGE (extension keeps it; epochs, not heartbeats), so a downstream store can reject a stale ex-holder after lease loss — the overlap serialized renewal narrows but cannot close. `LockState.token`; memory adapter implements it (monotonic for the adapter lifetime — the honest memory limit, documented). Boolean `tryAcquire` unchanged.
208
- - **`RepoCapabilities.transactionRetry: 'managed' | 'caller'`** — WHO owns retry of a conflict-aborted transaction. `retryingTransaction` used to wrap EVERY kit in its own 5-attempt loop, including MongoDB's convenient transaction API, which already re-runs the callback internally on `TransientTransactionError` / `UnknownTransactionCommitResult` for up to 120s. Two stacked policies: the callback's execution count stopped being bounded by `maxAttempts`, `onRetry` reported a fraction of real attempts, every outer attempt opened a NEW session, and misplaced side effects repeated unpredictably. Retry ownership is now DECLARED — `'managed'` is invoked exactly once, `'caller'` gets the envelope's loop. **Absent means `'managed'`**: a missing retry surfaces a conflict as a 409, a nested one re-runs side effects without bound, so silence must mean "don't" (same posture as `neverTransient`). `retryOwner` overrides for repositories that cannot declare.
209
- - **`retryingTransaction` is capability-aware** — a repository that publishes a descriptor is held to it (`transactions !== true` → throw, which `'unknown'` reports, failing closed). Method presence was never the test: kits expose `withTransaction` unconditionally and fail at BEGIN.
210
- - **`asReadOnlyRepo(repo, { reason })` + `RepoCapabilities.readOnly`** (`/repository`) — seal a repository whose rows another writer owns (Better Auth's identity collections, a SQL view, a read replica). Writes throw `ReadOnlyRepositoryError` (synchronously, so a forgotten `await` still crashes); reads pass through. A Proxy rather than a wrapper so a kit method this module has never heard of is sealed BY DEFAULT instead of leaking. `readOnly` lets a host refuse write ROUTES at boot rather than surfacing the wall on the first request.
211
- - **`TransactionHandle` is exported from `/repository`** — it was defined in `types.ts` but never re-exported, so consumers restated it structurally as `{ session?: unknown }` and would silently miss any field added to the contract.
212
- - **Conformance: capability-vs-behaviour assertions** — a transactional repo must declare `transactionRetry`, and `nestedTransactions` must match what a nested `withTransaction` call ACTUALLY does. Both were wrong in a shipped kit: mongokit declared `nestedTransactions: true` while its tx-bound proxy threw on the very same call.
213
- - **Conformance: `ifVersion CAS` block** (gated on `optimisticConcurrency`): matching version applies + increments, stale version throws `VersionConflictError` and the losing write does not apply, not-found stays `null`. New optional harness fields `versionField` / `missingId`.
214
-
215
- ## [0.22.0] - 2026-08-12
216
-
217
- ### Added — `pages: 0` conformance tests for empty results
218
-
219
- - `runStandardRepoConformance` now asserts `pages: 0` for empty offset envelopes (both the primary `getAll` path and the `aggregatePaginate` path). The field had no assertion anywhere in the suite; two kit paths diverged silently: the primary offset path computed `ceil(0/limit) = 0` while aggregate and lookup paths used `Math.max(1, ceil(...)) = 1`. The contract is `0` — zero rows fill zero pages — and it is what every kit's primary path already returned. The missing assertion let the drift reach a consumer as golden-fixture breakage when a `lookups` join rerouted a list read onto the divergent path.
220
-
221
- ## [0.21.0] - 2026-08-10
222
-
223
- ### Added — LRU-bounded `createMemoryCacheAdapter`
224
-
225
- - `createMemoryCacheAdapter(options?)` now accepts `MemoryCacheAdapterOptions` with a `maxEntries` cap (default `10,000`). Past the cap the least-recently-used entry is evicted — read promotion (delete + re-insert) keeps the map ordered by recency so eviction is O(1). A TTL alone does not bound the map: entries only expire when read, so a high-cardinality keyspace (per-tenant, per-commit, per-filter keys) grows monotonically until the process dies. Eviction is always safe for a cache, so the bound is on by default. Set `maxEntries: 0` to restore the previous unbounded behaviour.
226
- - `MemoryCacheAdapterOptions` exported from `@classytic/repo-core/cache`.
227
-
228
- ### Added — timezone-aware date buckets (`AggDateBucket.timezone`)
229
-
230
- - `AggDateBucket.timezone` — IANA zone the bucket boundaries are drawn in; absent means UTC. A UTC day is not a business day in any non-UTC deployment: rows from 18:00 to midnight local fall in the previous UTC day, silently reporting the wrong period in daily/monthly rollups.
231
- - `AggregateOpsSupport.dateBucketTimezone` capability flag — a kit that cannot draw DST-correct boundaries (e.g. SQLite, no tz database) MUST declare `dateBucketTimezone: false` and throw when the field is set rather than silently bucketing in UTC. Mongokit (`$dateTrunc` / `$dateToString` both accept `timezone`) declares `true`.
232
-
233
- ## [0.20.0] - 2026-08-04
234
-
235
- ### Added
236
-
237
- - **`ResolveBetterAuthCollectionsOptions.exclude`** — canonical collection names
238
- to omit from `resolveBetterAuthCollections()` output. Applied before
239
- `modelOverrides` / `usePlural` so a caller names the canonical collection
240
- (`'user'`) and never has to predict the final model name. Solves the collision
241
- when a host registers stub models for BA collections it merely references and a
242
- full `createBetterAuthOverlay` for the one it exposes CRUD on — both for the
243
- same collection would cause mongoose to lock the schema on first `model()`,
244
- silently dropping `additionalFields` and causing the overlay to refuse.
245
-
246
- ## [0.19.0] - 2026-07-29
247
-
248
- ### Added — `coerceFilterDates` + ISO date helpers (`./filter`)
249
-
250
- - **`coerceFilterDates(filter)`** — walks a record-shape filter (Mongo-dialect or bare bracket-op syntax) and coerces ISO-8601 strings on range operators (`gt`/`gte`/`lt`/`lte` and `$`-prefixed equivalents) to `Date`, recursing through `$and`/`$or`/`$nor`/`$not` logical wrappers. Returns a new object; the input is never mutated. Equality operators are deliberately excluded — a string that happens to look like a date is far more likely a string id than a date equality predicate. Fixes silent empty results on aggregation `$match` stages: MongoDB `$match` (unlike `find`) performs no schema casting, so a string compared to a Date column matches nothing.
251
- - **`tryCoerceIsoDate(value)`** — coerces one unknown value to a `Date` when it is an unambiguous ISO-8601 string; returns anything else untouched. Safe to apply unconditionally.
252
- - **`ISO_DATE_PATTERN`** — the single regex source of truth for tight ISO-8601 detection (date-only through millisecond precision + optional timezone). `query-parser/coerce.ts` now imports this instead of keeping a duplicate pattern — one definition means the URL boundary and the compile boundary can never disagree on what "looks like a date".
253
-
254
- All three are exported from `@classytic/repo-core/filter`. Purely additive.
255
-
256
- ## [0.18.0] - 2026-07-27
257
-
258
- ### Added — `definePurgeStep` builder (`./cleanup`) and `resolveTenantField` (`./tenant`)
259
-
260
- #### `./cleanup` — `definePurgeStep`
261
-
262
- - **`definePurgeStep(repository, spec)`** — standard builder for the most common
263
- `CleanupStep` shape: a chunked `purgeByField` over a single scope value.
264
- Nearly every provider step in a domain kernel is this pattern; the builder owns
265
- the invariant core so callers only declare what differs:
266
- - **fail-closed scoping** — missing scope value or unavailable repository is a
267
- `BLOCKER` (not a silent no-op and never an unscoped purge that would hit every
268
- tenant); blocked by `SCOPE_REQUIRED:<param>` / `REPOSITORY_UNAVAILABLE:<id>`.
269
- - **cancellation** — `throwIfCancelled` is called before work starts and the
270
- `signal` is threaded to the kit so a cancel lands between committed chunks.
271
- - **honest failure** — a failing purge returns `ok: false` so the recipe composer
272
- stops (retention §8); thrown errors are caught and reported, never swallowed.
273
- - **verification** — absence is re-queried after the run via `countDocuments`
274
- (`spec.verifyFilter` for steps whose match filter differs from the absence proof).
275
- - **`PurgeStepSpec`** — declaration object: `id`, `resource`, `parameter`, `field`,
276
- `strategy`, optional `retained`, `warnings`, `batchSize`, `guard(scope, ctx)`,
277
- `verifyFilter(scope)`, `verifyName`.
278
- - **`PurgeStepRepository`** — structural subset of `StandardRepo` satisfied by any
279
- kit repository that implements `purgeByField` + `countDocuments`.
280
- - **`SCOPE_REQUIRED`** / **`REPOSITORY_UNAVAILABLE`** — blocker-code prefix constants.
281
-
282
- #### `./tenant` — `resolveTenantField`
283
-
284
- - **`resolveTenantField(config?)`** — returns the single `tenantField` string a
285
- resource layer needs for `defineResource({ tenantField })`, or `false` when the
286
- config disables tenant scoping (`false` / `{ enabled: false }` / `{ strategy: 'none' }`).
287
- Four spine modules hand-rolled equivalent logic independently, each re-deriving
288
- `'organizationId'` as the default; one copy omitted the disable branch, making
289
- that package silently un-configurable. This function is the single definition.
290
-
291
- Both additions are purely additive — no breaking changes.
292
-
293
- ## [0.17.0] - 2026-07-25
294
-
295
- ### Added — `./cleanup`: framework-free cleanup provider step contract
296
-
297
- - **`@classytic/repo-core/cleanup`** — new subpath (pure types, no runtime).
298
- Domain kernels (`@classytic/flow`, `@classytic/order`, `@classytic/facts`,
299
- `@classytic/ledger`, …) export `CleanupStep[]` for the data they own; a host
300
- framework (`@classytic/arc/cleanup`) folds them into a Cleanup Center recipe.
301
- The contract lives in repo-core so kernels stay framework-free: kernels already
302
- depend on repo-core (the chunked-purge mechanics) and MUST NOT depend on arc.
303
- - **`CleanupStep`** — the core provider interface: `id`, `resource`, `destructive`,
304
- optional `rebuildActions`; and three lifecycle methods: `estimate(ctx)` (preview
305
- without mutating), `execute(ctx)` (chunked, idempotent, cancellation-aware),
306
- and optional `verify(ctx)` (post-checks — a delete count alone is never success).
307
- - **Supporting types**: `CleanupStepContext` (injected `now`, `signal`, `ambient`,
308
- `parameters`, `logger`), `CleanupStepExecuteContext` (adds `onProgress` +
309
- `throwIfCancelled`), `CleanupStepEstimate` (row count, `retained`, `blockers`,
310
- `warnings`), `CleanupStepOutcome` (`processed`, `ok`, `error`, `cursor`),
311
- `CleanupStepCheck` (`name`, `ok`, `detail`), `CleanupStepProgress`, `CleanupStepLogger`.
312
-
313
- Purely additive — zero runtime code, type-only subpath export.
314
-
315
- ## [0.16.0] - 2026-07-24
316
-
317
- ### Added — `runPurgeConformance`: cross-kit chunked-purge contract suite
318
-
319
- - **`runPurgeConformance(harness)`** (from `@classytic/repo-core/testing`) —
320
- proves a kit's purge port makes **stable progress for every strategy** when
321
- the match set exceeds `batchSize`. Every scenario seeds more rows than one
322
- batch; a chunk budget converts a non-progressing port's infinite loop into a
323
- crisp assertion failure. Scenarios: hard drain, soft multi-batch WITHOUT
324
- caller-supplied exclusion predicates, anonymize static + function-form,
325
- exact-batch boundary, skip, empty scope, abort-between-chunks.
326
- - **`PurgePort` progression contract (documented, mandatory)** — successive
327
- `purgeChunk` calls MUST advance through the match set for every strategy via
328
- stable keyset progression (`pk > lastSeen`, advanced only after the chunk's
329
- write succeeds). Re-running the bare predicate only self-advances for
330
- `hard`; `soft`/`anonymize` re-select the same first chunk forever. Offsets
331
- are explicitly ruled out. A port instance is single-run state.
332
-
333
- ### Fixed
334
-
335
- - `purgeByField` docstring no longer claims soft/anonymized rows "simply don't
336
- match the next pass" — they generally DO still match; idempotency holds by
337
- outcome convergence, and within-run progression is the port's keyset
338
- responsibility.
339
-
340
- ## [0.15.0] - 2026-07-24
341
-
342
- ### Added — `purgeByFilter`: range/filter-scoped purge + anonymize
343
-
344
- - **`StandardRepo.purgeByFilter?(filter, strategy, options)`** — the
345
- range/filter-scoped sibling of `purgeByField`. Where `purgeByField` matches
346
- a single `field = value` equality, this optional method takes the full
347
- portable `FilterInput` (Filter IR or a plain kit-native record) and runs any
348
- `TenantPurgeStrategy` (`hard` / `soft` / `anonymize` / `skip`) over the
349
- matched slice. THE compliance primitive for "purge/anonymize a dimension
350
- across a RANGE while RETAINING measures" — redact a PII column across a
351
- `civilDate` window, hard-delete rows past a retention cutoff, soft-delete a
352
- compound cohort. Returns the same `TenantPurgeResult` envelope; chunking,
353
- index requirement, idempotency, plugin composition, and narrowed-write
354
- re-assertion are identical to `purgeByField`. Gate on the new
355
- `capabilities.purgeByFilter`.
356
- - **`RepoCapabilities.purgeByFilter?: boolean`** — feature-detection flag,
357
- mirroring `purgeByField`.
358
- - **`PurgePort` doc** clarifies the two bound-predicate forms (equality-bound
359
- vs filter-bound) both satisfy the single port interface, so `runChunkedPurge`
360
- drives both unchanged.
361
-
362
- Strictly additive: `purgeByField`, `TenantPurgeStrategy`, and `runChunkedPurge`
363
- are unchanged; both new members are optional.
364
-
365
- ## [0.14.0] - 2026-07-16
366
-
367
- ### Added — canonical `matchesRecordFilter` (the `DataAdapter.matchesFilter` home)
368
-
369
- - **`matchesRecordFilter(item, record)`** + **`policyRecordToFilter(record)`**
370
- (`@classytic/repo-core/filter`) — THE single, shared implementation of the
371
- `DataAdapter.matchesFilter` seam. Evaluates arc's Mongo-record `_policyFilters`
372
- (`{ ownerId }`, `{ organizationId }`, `{ $or: [{ ownerId }, { _id: { $in } }] }`)
373
- against an already-fetched document IN PROCESS, by converting to the portable
374
- `Filter` IR and delegating to `matchFilter` — the SAME IR kits compile to
375
- SQL/Mongo, so in-memory and DB enforcement agree by construction. Every kit's
376
- adapter now delegates here; **no per-kit matcher, one contract, one IR.**
377
- Operator scope: `$or`/`$and`/`$nor`/`$not`, `$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`,
378
- `$in`/`$nin`, `$exists`, implicit-eq; fails LOUD on anything else. (Distinct from
379
- `recordToFilter`, the bare-operator query normalizer that does not accept
380
- `$`-prefixed or logical operators.)
381
- - **`matchFilter` is now fully array + id + date aware** (additive superset;
382
- primitive/Date-vs-Date behavior unchanged — strict cases still short-circuit):
383
- - **id coercion** — an id-like object with a meaningful `toString` (Mongo
384
- `ObjectId`, `Buffer`, `Decimal128`) matches its string form, so one shared
385
- matcher serves Mongo (`ObjectId` `_id`) and SQL (primitive ids) alike.
386
- - **array semantics** — dot-paths fan out over subdocument arrays
387
- (`items.sku` on `[{sku},{sku}]`), and a scalar condition on a leaf array
388
- field matches when ANY element satisfies it (`{ tags: 'x' }`,
389
- `{ scores: { $gt: 5 } }`, regex on array elements). All array unwrapping is
390
- concentrated in one helper; the comparators stay pure scalar.
391
- - **Date⇄ISO-string range** — `compare` coerces the string side to a date
392
- instant when the other side is a genuine `Date` (mirrors `equals`), and
393
- never claims ordering across a number/string type boundary (fails closed
394
- instead of matching spuriously).
395
- - **prototype-pollution-safe reads** — path resolution uses `Object.hasOwn`,
396
- so a crafted `{ '__proto__.x': … }` / `{ constructor.name: … }` filter can
397
- never traverse the prototype chain.
398
- - **Ability parity across kits**: mongokit's earlier standalone matcher
399
- (subdocument-array fan-out, array-contains, `$regex`, ObjectId coercion) is now
400
- fully covered by the shared engine — nothing was lost in consolidation.
401
- - **MongoDB-parity hardening** (validated against the MongoDB manual + sift.js +
402
- mingo — the two industry-standard in-memory matchers). The authorization-critical
403
- rule "an absent field participates in comparisons as null/undefined" is now
404
- fully honored:
405
- - `{ field: null }`, `$ne`, `$nin`, and a `null` MEMBER of `$in`/`$nin` all
406
- match a MISSING field (a policy filter `{ status: { $ne: 'archived' } }`
407
- correctly returns docs that lack the field, exactly as MongoDB does — the top
408
- silent-authorization-divergence trap). `$ne: null` remains the exception
409
- (requires present + non-null).
410
- - `$in` accepts RegExp-literal members (`{ name: { $in: [/^a/] } }`).
411
- - Comparison ops are type-bracketed (no cross-type ordering; `$gt: null` matches
412
- nothing); NaN equals NaN for `$eq` (via `Object.is`, not `===`).
413
- - Numeric dot-path segments resolve as positional array indices (`items.0.sku`).
414
- - Documented DELIBERATE divergences: `$exists` = present-and-non-null (matches
415
- the IR `exists` op + SQL `IS NOT NULL` + sift; Mongo/mingo count present-null
416
- as existing); `Date`⇄ISO-string range leniency; array-literal operands are
417
- element-matched not exact-matched.
418
- - New: 41-case matcher suite incl. the 11 researched MongoDB gotchas + adversarial
419
- (prototype pollution, NaN/Infinity, boolean/zero/empty-string, empty
420
- `$in`/`$or`/`$and`/`$nor`, fail-loud on unsupported operators) + a 100k-doc
421
- performance smoke (linear, cached regex).
422
- - **Security hardening** (from a review against sift.js/mingo CVEs + the OWASP
423
- NoSQL-injection / ReDoS / prototype-pollution literature):
424
- - **Prototype-key denylist** on path segments — `__proto__` / `constructor` /
425
- `prototype` resolve to nothing (fail-closed), string-normalized (the
426
- object-path CVE-2021-23434 lesson: an array-typed segment bypassed a `===`
427
- check). Closes the "match an inherited member → wrong auth answer" case and
428
- the `JSON.parse('{"__proto__":…}')` own-property vector, on top of the
429
- already-`Object.hasOwn` reads.
430
- - **`$regex` input-length cap** (64 KiB) — ReDoS is `pattern × input`; a field
431
- value longer than the cap is treated as no-match (fail-closed) so one slow
432
- match can't stall the event loop and amplify across a realtime fan-out
433
- (matcher runs once per subscriber per record). Pattern-side ReDoS is a
434
- non-issue for framework-supplied (trusted) patterns.
435
- - Reaffirmed **fail-closed** posture: comparisons never coerce across a
436
- number/string type boundary (return no-match rather than JS-coerced nonsense
437
- — the classic over-visibility leak), and unsupported operators throw.
438
-
439
- ## [0.13.0] - 2026-07-15
440
-
441
- ### Added — `StandardRepo.applyTransition?()` contract (state-machine CAS with history)
442
-
443
- - **`TransitionMachine`** — the canonical structural contract for the state
444
- machine (`{ name, assertTransition }`). Primitives'
445
- `defineStateMachine()` output and mongokit's local `TransitionMachine`
446
- are structurally identical by design; nobody imports anybody (same
447
- policy as `ClaimTransition`).
448
- - **`TransitionArgs`** — `{ from, to, field?, set?, push?, where?, by?,
449
- note?, history?, at? }`.
450
- - **`StandardRepo.applyTransition?(id, machine, args, options)`** — OPTIONAL
451
- member declaring the state-machine-backed CAS transition mongokit 3.22
452
- ships as a class primitive: legality pre-flight via the machine (which
453
- throws the domain's typed error), CAS via `claim`, `$push`ed status
454
- history, and accurate race-loss diagnosis (machine error from the row's
455
- CURRENT state / 404 `TRANSITION_TARGET_MISSING` / 409
456
- `TRANSITION_RACE_LOST`). Unlike `claim`/`claimVersion` it THROWS rather
457
- than returning `null` — it owns the error liturgy domain packages used
458
- to copy-paste. Promote to required once sqlitekit implements it.
459
-
460
- ### Added — `./usage` subpath (period-bucketed counter contract)
461
-
462
- - **`UsageStore`** — driver-agnostic interface for atomic period-bucketed counters: `increment(bucket, amount)` + `summary(actor, period)`. The storage seam under platform accounting (quotas, plan enforcement, usage-based billing). One cell = `(actor, period, kind)`; one write = atomic upsert; one read = all counters for an actor-period pair. Kits ship adapters (`@classytic/mongokit/usage`, `@classytic/sqlitekit/usage`, …) without depending on arc; `@classytic/arc/usage` consumes this contract structurally.
463
- - **`UsageBucket`** — `{ actor, period, kind }` tuple. `kind` is dot-namespaced: `api.requests`, `ai.tokens.input`, `storage.egress.bytes`.
464
- - **`usagePeriod(date?)`** — canonical UTC calendar-month key (`"2026-07"`). Monthly is the billing-native granularity.
465
- - **`createMemoryUsageStore()`** — in-process reference implementation for tests and single-instance apps. Returns `UsageStore & { clear() }`. Multi-replica deployments need a shared adapter.
466
-
467
- ### Added — `runUsageStoreContract` in `./testing`
468
-
469
- - **`runUsageStoreContract(harness)`** — cross-kit conformance suite. Kits import once and pass their adapter; all canonical scenarios run automatically. Same pattern as `runLockAdapterConformance`. `UsageConformanceHarness` is exported from `./testing` for harness typing.
470
-
471
- ## [0.10.0] - 2026-07-13
472
-
473
- ### Added — `ValidationErrorMeta.path` + `.meta` (field-scoped validation errors)
474
-
475
- - **`ValidationErrorMeta.path?: string`** — dot-path to the offending field
476
- (e.g. `'journalItems.2.account'`). Set by kits with field-scoped validation
477
- (ledger's `FieldError`, Mongoose `ValidationError`). Absent when the kit doesn't
478
- have a field path.
479
- - **`ValidationErrorMeta.meta?: Readonly<Record<string, unknown>>`** — non-PII
480
- structured extra for the field (e.g. `{ value: 'bad' }`). Never include secrets.
481
- - **`toErrorContract`** now forwards `path` and `meta` from each `validationErrors`
482
- entry onto the wire `ErrorDetail`. Previously only `code` and `message` were mapped;
483
- field paths were silently dropped. No breaking change — both fields are optional and
484
- additive. Kits that don't set them produce the same wire shape as before.
485
-
486
- ## [0.9.0] - 2026-07-11
487
-
488
- ### Added — `StandardRepo.getByIds` (batch point-read)
489
-
490
- - **`getByIds?(ids: readonly string[], options?: FindAllOptions): Promise<Map<string, TDoc>>`**
491
- added to `StandardRepo` — the `$in` / `IN (...)` counterpart to `getById`. The N+1
492
- killer for callers resolving many ids in one tick (order-line snapshotting, dashboard
493
- row enrichment, reference expansion).
494
-
495
- **Contract:**
496
- - Kit de-duplicates ids before the driver round-trip.
497
- - Ids with no matching doc are ABSENT from the returned `Map` — never throw on a miss.
498
- - Structurally invalid ids are treated as misses (one malformed id must never fail the
499
- batch).
500
- - Tenant scoping, soft-delete, and hooks apply exactly as they would to `getById`.
501
- - Empty input → empty Map, no round-trip.
502
- - Optional: kits adopt incrementally. Callers feature-detect
503
- (`repo.getByIds ?? per-id fallback`) until their kit floor ships it.
504
- - Sizing is backend-bound (Mongo: 16 MB query document); keep batches ≤ ~10k ids and
505
- chunk beyond.
506
-
507
- - **`events.ts` clarification** — `getByIds` has no `BEFORE_GET_BY_IDS` event by design.
508
- It routes through `findAll`, so observers see the batch as `before:findAll` / `after:findAll`
509
- with an `$in` filter. The comment guards against accidentally adding a separate event.
510
-
511
- - **New type-level contract test** (`tests/unit/repository/get-by-ids-contract.test.ts`) —
512
- pins the `getByIds` shape for kits implementing it: `Map<string, TDoc>` keyed by
513
- stringified id, `readonly string[]` input. Verifies that kits without `getByIds` still
514
- conform (the member is optional).
515
-
516
- ### Changed
517
-
518
- - `ChangesPage.changes` type: `ReadonlyArray<ChangeEntry<TDoc>>` → `readonly ChangeEntry<TDoc>[]`
519
- (canonical form — functionally identical).
520
- - devDeps: `tsdown ^0.21.8 → ^0.22.5`, `typescript ^6.0.3 → ^7.0.2`.
521
-
522
- ## [0.8.1] - 2026-07-11
523
-
524
- ### Docs — `DataAdapter.close()` ownership rule
525
-
526
- - Clarified the `close?()` contract (doc-only, no type/runtime change): the adapter does **not** own the database connection — the host does, and `close()` must release only adapter/kit-owned resources (TTL/vacuum timers, change streams), never disconnect a shared client/pool. Kits that genuinely own a connection gate its disposal behind an explicit opt-in. This standardizes cleanup semantics across mongokit/sqlitekit/pgkit/prismakit.
527
-
528
- ## [0.8.0] - 2026-07-08
529
-
530
- ### Added — data-lifecycle contract (archive, streaming, distribution awareness)
531
-
532
- Additive release: existing kits on 0.7 semantics compile and pass conformance unchanged (new scenarios are capability-gated and default to skipped).
533
-
534
- - **`archiveByFilter` + `runChunkedArchive`** (`repository/archive.ts`) — the cold-storage twin of `purgeByField`. Hosts provide an `ArchiveSink` (archive table/collection, JSONL, warehouse loader); kits provide an `ArchivePort` (`readChunk` in stable PK order / `deleteChunk` by docs). The orchestrator enforces **write-before-delete** (a crash re-archives, never loses; sinks must be duplicate-tolerant — at-least-once by design), chunked `batchSize` (default 1000), per-step retry via the shared `RetryPolicy`, abort between chunks, cumulative progress, and phase-tagged errors (`read` / `sink` / `delete` — a `sink` failure guarantees rows are still hot). New capability flag: `archiveByFilter`.
535
- - **`cursor()` declared on `StandardRepo`** (+ `CursorOptions`) — the streaming-reads method mongokit and sqlitekit already implement is now part of the portable contract (`AsyncIterable<TDoc>`, internal `batchSize` fetching, non-snapshot semantics documented). Gated by the existing `streaming` capability.
536
- - **Distribution-key awareness** (`repository/distribution.ts`) — `DistributionConfig` (`key`, `onMissingKey: 'warn' | 'throw' | 'off'`, `exemptOperations`) + `createDistributionGuard` + `filterReferencesKey` (Filter IR via `collectFields`, raw records incl. `$and`/`$or`/`AND`/`OR` branches). Catches shard/partition-key-missing scatter-gather queries at the access layer; deliberately does NOT implement routing/rebalancing/partition DDL — those stay database-native (Mongo sharding, pg_partman/Timescale).
537
- - **Conformance**: new capability-gated scenario groups — `archiveByFilter` (sink round-trip, write-before-delete on sink failure, chunking + progress, idempotent re-run, abort partial) and `cursor` (exact-once iteration across batch boundaries, early-break safety).
538
-
539
- ## [0.7.0] - 2026-07-04
540
-
541
- ### Added — `./sync` change-log / cursor contract
542
-
543
- Storage-agnostic data-sync spine for offline-first + incremental replication:
544
- `ChangeLogStore`, `ChangeEntry` (tombstone deletes), pull `ChangesPage`
545
- (opaque exclusive cursors, `hasMore` paging), Replicache-style `PushMutation`
546
- / `PushVerdict` (idempotent client mutation ids, server-authoritative
547
- conflicts), `CursorExpiredError` (compaction horizon → full resync), and the
548
- `MemoryChangeLogStore` reference impl pinning the contract semantics.
549
-
550
- Synthesized from CouchDB `_changes` (tombstones/checkpoints), Drive/Graph
551
- delta APIs (opaque server-issued cursors), Mongo change-stream resume tokens,
552
- and Replicache push/pull. Contract only — kits (mongokit/prismakit/sqlitekit)
553
- implement capture + durable stores via repository plugins; arc re-exports the
554
- surface at `@classytic/arc/sync`; sync HTTP endpoints are an arc module.
555
-
556
- ## [0.6.1] - 2026-07-04
557
-
558
- ### Changed — `TenantConfig` optionals widened to `T | undefined` (P10)
559
-
560
- Every optional prop on `TenantConfig` (`strategy`, `enabled`, `tenantField`,
561
- `fieldType`, `ref`, `contextKey`, `required`, `resolve`) is now typed
562
- `T | undefined`, so downstream packages compiling with
563
- `exactOptionalPropertyTypes: true` can extend it without redeclaring props
564
- (unblocks `@classytic/ledger`'s `MultiTenantConfig.required` exception).
565
-
566
- `resolveTenantConfig` strips explicit-`undefined` keys before spreading
567
- over `DEFAULT_TENANT_CONFIG`, so `{ required: maybeUndefined }` can never
568
- clobber a default with `undefined` — previously the un-widened type made
569
- that unrepresentable; now it's handled at runtime and pinned by tests.
570
- `DEFAULT_TENANT_CONFIG`'s annotation moved from `Required<Pick<...>>` to an
571
- `Exclude<..., undefined>` mapping (`-?` does not strip an explicit
572
- undefined union member). No runtime behavior change for existing callers.
573
-
574
- ## [0.6.0] - 2026-06-11
575
-
576
- Standardization release. Coordinated with mongokit 3.16 + sqlitekit 0.6.
577
-
578
- ### Added
579
-
580
- - **`StandardRepo.capabilities: RepoCapabilities`** (required) — runtime feature detection (`arrayOperators`, `changeStreams`, `regexFilter`, `lean`, `streaming`, `lookupPopulate`, + conformance flags). `ConformanceFeatures` is now an alias of the same type — runtime declaration and conformance gating cannot drift.
581
- - **Standard Schema validation** — `RepositoryBaseOptions.schema` / `updateSchema` accept any Zod / Valibot / ArkType / Effect schema; runs at new `HOOK_PRIORITY.VALIDATION` (150). Failures throw `HttpError` 400 with `validationErrors`. Vendored `StandardSchemaV1` types + `validateStandardSchema()` on `/schema`.
582
- - **Domain events** (`/events` subpath) — `RepositoryBaseOptions.events: { transport }` publishes `<resource>.<verb>` events through any arc/primitives-compatible transport. Publish failures never fail the op (routed to `error:events`).
583
- - **Change feed contract** — optional `StandardRepo.watch?()` returning `AsyncIterable<ChangeEvent<TDoc>>`; gated by `capabilities.changeStreams`.
584
- - **Resilience** — `QueryOptions.signal` + `QueryOptions.retryPolicy`; shared `withRetry()` / `throwIfAborted()` helpers.
585
- - **`recordToFilter()`** on `/filter` — promoted from per-kit copies.
586
- - `STANDARD_REPO_OPTION_KEYS` gains `'traceId'`.
587
-
588
- ### Changed
589
-
590
- - `PurgeRetryPolicy` → **`RetryPolicy`** (rename, identical shape; was declared twice — now once). Clean break, no alias.
591
- - `AggregateOpsSupport` moved to `repository/capabilities.ts` (still re-exported from `/testing`).
592
-
593
- ## [0.5.0] - 2026-05-17
594
-
595
- ### Added — compliance-grade tenant cleanup primitive
596
-
597
- Cross-kit foundation for "what happens to this data on org-delete?" —
598
- GDPR right-to-be-forgotten, SOC 2 deletion timelines, HIPAA / PCI
599
- retention rules. Every kit (mongokit, sqlitekit, future pgkit) gets
600
- the same surface; arc's `cascadeDeleteForOrganization` runner composes
601
- on top.
602
-
603
- - **`StandardRepo.purgeByField?(field, value, strategy, options)`** — new optional method. Processes every row matching `field = value` under a declared strategy, chunked under the hood. Optional because not every store needs the surface; arc's cascade runner checks for the method at boot.
604
- - **`TenantPurgeStrategy`** discriminated union — four variants:
605
- - `{ type: 'hard' }` — permanent removal (GDPR right-to-be-forgotten).
606
- - `{ type: 'soft', deletedField?, deletedAtField? }` — recoverable; pairs with TTL indexes for eventual hard-purge.
607
- - `{ type: 'anonymize', fields }` — retain rows but overwrite declared fields (HIPAA / PCI / SOX-compatible).
608
- - `{ type: 'skip', reason }` — explicit opt-out with **mandatory** `reason` (compliance forcing function — silent skips are leaks).
609
- - **`TenantPurgeOptions`** — `batchSize`, `session`, `onProgress`, `signal`. Chunking is mandatory (10M-row tenants can't run as a single `deleteMany`); abort signal is checked between chunks (never mid-write); aborted runs return `ok: false` with cumulative `processed` count (at-least-once cleanup semantics).
610
- - **`TenantPurgeResult`** + **`TenantPurgeProgress`** — typed result envelope + per-chunk progress event.
611
-
612
- ### Added — kit-agnostic orchestrator (`runChunkedPurge`)
613
-
614
- The chunk-loop logic — abort handling, progress emission, error-wrapping into result envelope, natural-exit on non-full batch — is identical across kits. Extracting it here means a single bug fix lands for every kit, and the surface a new kit has to implement shrinks to ~80 lines.
615
-
616
- - **`runChunkedPurge(strategy, options, port)`** — pure orchestrator (130 lines, no I/O).
617
- - **`PurgePort`** interface — the driving port. Each kit implements a single closure: `purgeChunk(strategy, limit)` — one method lets each driver pick its own round-trip shape (sqlite hard-strategy compiles to one `DELETE … LIMIT`; a two-method `selectChunkIds` + `applyStrategy` split would force 2 round-trips per chunk for every kit).
618
- - **`WritingPurgeStrategy`** — strategy union with `skip` excluded (orchestrator handles `skip` before the port is consulted, so ports only see `hard` / `soft` / `anonymize`).
619
-
620
- Hexagonal pattern: orchestrator is the use-case, `PurgePort` is the driving port, each kit's port factory is the adapter. Adding a new strategy (e.g. `archive`) = one union member + one case per port. Adding a new kit = one port file + ~10-line method.
621
-
622
- ### Added — 8 cross-kit conformance scenarios
623
-
624
- In `src/testing/conformance.ts`, gated by the new `ConformanceFeatures.purgeByField?: boolean` flag. When both mongokit and sqlitekit pass the same scenarios, cross-kit byte-stability for tenant cleanup is provable:
625
-
626
- 1. `hard` removes every matching row, leaves others intact
627
- 2. `hard` empty match → `processed: 0`, `ok: true`
628
- 3. `anonymize` overwrites declared fields, keeps the row
629
- 4. `skip` is a no-op, returns reason
630
- 5. Chunking: `batchSize` honored, `onProgress` fires per chunk
631
- 6. Idempotent: re-running on the same tenant is a no-op
632
- 7. Scoping: only matching rows affected (cross-tenant safety)
633
- 8. Abort signal: stops between chunks, returns partial count + `ok: false`
634
-
635
- The `soft` strategy is intentionally NOT in the conformance suite — it requires writable `deleted` / `deletedAt` fields not present on the shared `ConformanceDoc`; each kit covers `soft` in its own integration tests.
636
-
637
- ### Migration notes
638
-
639
- - **Existing kits:** the new method is optional — kits don't break. mongokit 3.14.0 and sqlitekit 0.4.0 ship implementations; older kit versions continue to work, they just can't honor a `purgeByField` call.
640
- - **Existing hosts:** no breaking changes. Hosts using arc's `cascadeDeleteForOrganization` automatically pick up the new strategy surface once arc 2.16.0 lands.
641
- - **Build sync (workspace dev):** kits need `cp -r dist/* ../mongokit/node_modules/@classytic/repo-core/dist/` etc. after a workspace bump until npm publish.
642
-
643
- ## [0.4.0] - 2026-05-04
644
-
645
- ### Added — kit-shared building blocks (consolidation)
646
-
647
- - **`@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.
648
- - **`@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.
649
-
650
- ### ⚠️ BREAKING — `cache/deferred.ts` removed (use `Promise.withResolvers()`)
651
-
652
- - **`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.
653
- - **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.
654
-
655
- ### Fixed — security & robustness hardening
656
-
657
- - **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`)
658
- - **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`)
659
- - **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`)
660
-
661
- ### Cache hash upgrade (djb2 → FNV-1a 64-bit)
662
-
663
- - **`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.
664
- - **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.
665
-
666
- ### Cache layer — atomic counters, parallel invalidation, prefetch, timeout
667
-
668
- - **`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).
669
- - **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.
670
- - **`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.
671
- - **`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.
672
- - **`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.
673
-
674
- ### Cache layer — production hardening (TanStack-aligned) + cross-runtime
675
-
676
- Six gaps in the v1 unified cache layer fixed before any prod traffic:
677
-
678
- 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.
679
- 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.
680
- 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.)
681
- 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.
682
- 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.
683
- 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 })`.
684
-
685
- ### Code organization
686
-
687
- The unified plugin (`@classytic/repo-core/cache`) is now split into focused modules:
688
-
689
- ```
690
- cache/
691
- plugin/
692
- index.ts # cachePlugin factory + types + handle (~200 LOC)
693
- context.ts # typed context slots + extraction + shape-keys (~170 LOC)
694
- read-hooks.ts # before/after/error for read ops (~180 LOC)
695
- invalidation-hooks.ts # after for write ops (~60 LOC)
696
- swr.ts # background-refresh scheduler (~50 LOC)
697
- engine.ts # TTL + SWR + tag + version + single-flight (~220 LOC)
698
- runtime.ts # cross-runtime scheduleBackground (~50 LOC)
699
- ...
700
- ```
701
-
702
- Each module has one purpose. Replaces the prior 564-LOC `plugin.ts` mega-file.
703
-
704
- ### Added — `Deferred<T>` utility
705
-
706
- `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.
707
-
708
- ### Added — Unified cache layer (`@classytic/repo-core/cache`)
709
-
710
- 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.
711
-
712
- #### Public surface (`@classytic/repo-core/cache`)
713
-
714
- ```ts
715
- import {
716
- cachePlugin, // hook integration — plugs into RepositoryBase
717
- CacheEngine, // direct SWR + TTL + tag flow over a CacheAdapter
718
- buildEnvelope, inspectEnvelope, type CacheEnvelope,
719
- buildCacheKey, extractScopeTags, type BuildKeyInput,
720
- appendKeyToTags, invalidateByTags as invalidateByTagsImpl,
721
- bumpModelVersion, getModelVersion,
722
- resolveCacheOptions, type CacheOptions, type ResolvedCacheOptions, type CacheReadResult,
723
- // already shipped:
724
- type CacheAdapter, createMemoryCacheAdapter, stableStringify,
725
- } from '@classytic/repo-core/cache';
726
- ```
727
-
728
- #### TanStack Query-shaped per-call options
729
-
730
- Same shape across CRUD + aggregate, kit-agnostic:
731
-
732
- ```ts
733
- {
734
- staleTime?: number; // seconds fresh
735
- gcTime?: number; // seconds retained past stale (default 60)
736
- swr?: boolean; // serve-stale + bg refresh
737
- tags?: readonly string[];
738
- bypass?: boolean;
739
- enabled?: boolean;
740
- key?: string; // explicit override
741
- }
742
- ```
743
-
744
- #### What the plugin does
745
-
746
- 1. Subscribes to `before:<op>` / `after:<op>` for every read op (`getById`, `getAll`, `getOne`, `getByQuery`, `count`, `exists`, `distinct`, `aggregate`, `aggregatePaginate`) — configurable via `enabled: [...]`.
747
- 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).
748
- 3. Auto-injects scope tags (`org:<id>`, `user:<id>`) from `context.filter` so cross-tenant cache poisoning is structurally impossible.
749
- 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.
750
- 5. Attaches `repo.cache` handle exposing `invalidateByTags(tags)`, `bumpModelVersion(model)`, `clear()`.
751
-
752
- #### Strictly-monotonic version bumps
753
-
754
- `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.
755
-
756
- #### `AggCacheOptions` is now an alias for `CacheOptions`
757
-
758
- 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.
759
-
760
- #### Removed
761
-
762
- - `/aggregate-cache` subpath — superseded by the unified `/cache` plugin (which handles aggregate ops natively via the `before:aggregate` hook).
763
-
764
- #### Migration (kit + host)
765
-
766
- ```ts
767
- // Before — kit-specific cache plugins + constructor option
768
- new Repository(model, [cachePlugin({ adapter, ttlSeconds: 60 })], {}, {
769
- aggregateCache: adapter, // separate constructor option
770
- });
771
- repo.aggregate({ measures, cache: { ttl: 60, staleWhileRevalidate: true } });
772
-
773
- // After — one plugin, one shape
774
- new Repository(model, [
775
- multiTenantPlugin({ tenantField: 'orgId' }),
776
- cachePlugin({ adapter, defaults: { staleTime: 60, gcTime: 300, swr: true } }),
777
- ]);
778
- repo.aggregate({ measures, cache: { staleTime: 60, swr: true, tags: ['orders'] } });
779
- repo.getAll(filter, { cache: { staleTime: 30 } });
780
- await repo.cache?.invalidateByTags(['orders']);
781
- ```
782
-
783
- ### Added — `StandardRepo.claim()` and `claimVersion()` (atomic CAS, REQUIRED on the contract)
784
-
785
- Standardizes the canonical state-machine write that every domain package was hand-rolling on top of `findOneAndUpdate`:
786
-
787
- ```ts
788
- const claimed = await repo.claim?.(runId, { from: 'waiting', to: 'running' }, {
789
- lastHeartbeat: new Date(),
790
- workerId: 'worker-12',
791
- });
792
- if (!claimed) return; // someone else got it
793
- ```
794
-
795
- **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.
796
-
797
- **Pairs with `@classytic/primitives/state-machine`** — different layers:
798
- - `defineStateMachine()` answers "is `from → to` legal in the model?" (compile-time table + early throw)
799
- - `claim()` answers "did we win the transition vs concurrent writers?" (runtime null on race)
800
-
801
- 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.
802
-
803
- #### New types exported from `@classytic/repo-core/repository`
804
-
805
- - `ClaimTransition` — `{ field?, from, to, where? }` argument shape for `claim` (`where` is the compound-CAS predicate slot — see below).
806
- - `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).
807
-
808
- #### Added to `StandardRepo<TDoc>` — REQUIRED methods (not optional)
809
-
810
- ```ts
811
- claim(
812
- id: string,
813
- transition: ClaimTransition,
814
- patch?: Partial<TDoc>,
815
- options?: WriteOptions,
816
- ): Promise<TDoc | null>;
817
-
818
- claimVersion(
819
- id: string,
820
- transition: ClaimVersionTransition,
821
- update: Record<string, unknown>,
822
- options?: WriteOptions,
823
- ): Promise<TDoc | null>;
824
- ```
825
-
826
- **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.
827
-
828
- #### `ClaimTransition.where` — compound-CAS predicate
829
-
830
- 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.
831
-
832
- `ClaimTransition.where` AND-merges arbitrary predicates alongside the canonical id + state-field match:
833
-
834
- ```ts
835
- const claimed = await repo.claim?.(runId, {
836
- from: 'waiting',
837
- to: 'running',
838
- where: {
839
- paused: { $ne: true },
840
- 'scheduling.retryAfter': { $lte: new Date() },
841
- },
842
- }, { lastHeartbeat: new Date() });
843
- ```
844
-
845
- Cross-kit notes:
846
- - Mongokit: ANDed into the `findOneAndUpdate` filter.
847
- - SQL kits: ANDed into the `WHERE` clause (raw column literals accepted; portable Filter IR is compiled).
848
- - Prismakit: merged as additional keys on the `where` object.
849
-
850
- 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."
851
-
852
- 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.
853
-
854
- #### `ClaimTransition.from` widened to `unknown | readonly unknown[]` — multi-source CAS
855
-
856
- 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).
857
-
858
- ```ts
859
- // "From any non-terminal state to voided"
860
- await repo.claim?.(id, { from: ['pending', 'approved', 'sent'], to: 'voided' });
861
- ```
862
-
863
- Single-value `from` is unchanged (back-compatible). Array form is opt-in — pass an array to enable.
864
-
865
- **`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.
866
-
867
- #### Migration
868
-
869
- Pre-0.4.0 callers wrote:
870
-
871
- ```ts
872
- const claimed = await repo.findOneAndUpdate(
873
- { _id: id, status: 'waiting' },
874
- { $set: { status: 'running', lastHeartbeat: new Date() }, },
875
- );
876
- ```
877
-
878
- Post-0.4.0:
879
-
880
- ```ts
881
- const claimed = await repo.claim?.(id, { from: 'waiting', to: 'running' }, { lastHeartbeat: new Date() });
882
- ```
883
-
884
- The old form keeps working — `claim()` is an additive optional method, not a rename.
885
-
886
- ## [0.3.0] - 2026-04-29
887
-
888
- ### Added — Aggregate pagination shapes
889
-
890
- - `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.
891
- - `AnyPaginationResult<TDoc, TExtra>` — union over the three result shapes. Use as the input type to anything that converts repo results into HTTP envelopes.
892
-
893
- ### Added — HTTP wire envelopes
894
-
895
- 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.
896
-
897
- - `OffsetPaginationResponse<TDoc, TExtra>` = `{ success: true } & OffsetPaginationResult<TDoc, TExtra>`
898
- - `KeysetPaginationResponse<TDoc, TExtra>` = same for keyset
899
- - `AggregatePaginationResponse<TDoc, TExtra>` = same for aggregate
900
- - `BareListResponse<TDoc>` = `{ success: true; docs: TDoc[] }` for endpoints that don't paginate
901
- - `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')`.
902
-
903
- ### Added — `toCanonicalList()` runtime normalizer
904
-
905
- ```ts
906
- import { toCanonicalList } from '@classytic/repo-core/pagination';
907
-
908
- const result = await userRepo.getAll(query);
909
- reply.send(toCanonicalList(result)); // → PaginatedResponse<User>
910
-
911
- reply.send(toCanonicalList([u1, u2])); // → BareListResponse<User>
912
- ```
913
-
914
- 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.
915
-
916
- **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.
917
-
918
- ### Added — `isPaginatedResult()` type guard
919
-
920
- 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.
921
-
922
- ### Test delta
923
-
924
- 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).
925
-
926
- ### Added — `SchemaGenerator<TModel>` interface in `/schema`
927
-
928
- 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.
929
-
930
- - `SchemaGenerator<TModel = unknown>` — `(model, options?, context?) => CrudSchemas | Record<string, unknown>`.
931
- - `SchemaGeneratorContext` — resource-level context threaded at boot (`idField`, `resourceName`).
932
- - `isSchemaGenerator(value)` — runtime predicate (arity 1-3 functions). Conservative — doesn't invoke.
933
-
934
- Each kit ships a compile-time conformance check (same playbook as mongokit's `RepositoryLike` conformance gate):
935
-
936
- ```ts
937
- const _conformance: SchemaGenerator<Model<unknown>> = buildCrudSchemasFromModel;
938
- ```
939
-
940
- Drift surfaces in the kit's typecheck immediately, before any consumer sees it.
941
-
942
- 10 new tests in `tests/unit/schema/generator.test.ts`. Total repo-core: 293 → 303.
943
-
944
- ### Added — `errors` module: canonical wire + throwable error contract
945
-
946
- `@classytic/repo-core/errors` is now the single source of truth for error contracts across the org. Two complementary shapes:
947
-
948
- - **`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).
949
- - **`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.
950
- - **`ErrorDetail`** — single field-scoped error (path / code / message). `ErrorContract.details` is `ReadonlyArray<ErrorDetail>`.
951
- - **`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'`).
952
- - **`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.
953
- - **`statusToErrorCode(status)`** — well-known HTTP status → canonical code. Conservative mapping; unknown statuses fall through to `'internal_error'` so domain handlers explicitly opt in.
954
-
955
- 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.
956
-
957
- 14 new tests in `tests/unit/errors/contract.test.ts`. Total repo-core: 279 → 293.
958
-
959
- ### Added — `tenant` subpath (canonical home for tenant scope contract)
960
-
961
- New subpath `@classytic/repo-core/tenant` ships:
962
- - `TenantConfig` — static config (`strategy`, `enabled`, `tenantField`, `fieldType`, `ref`, `contextKey`, `required`, `resolve`).
963
- - `TenantStrategy = 'field' | 'none' | 'custom'`, `TenantFieldType = 'objectId' | 'string'`.
964
- - `ResolvedTenantConfig` — the resolved-with-defaults shape returned by `resolveTenantConfig`.
965
- - `DEFAULT_TENANT_CONFIG` — sensible org-wide defaults (`tenantField: 'organizationId'`, `fieldType: 'objectId'`, `ref: 'organization'`, `required: true`).
966
- - `resolveTenantConfig(config?)` — normaliser; validates `'custom'` strategy requires `resolve`.
967
-
968
- 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.
969
-
970
- **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`.
971
-
972
- 14 new tests in `tests/unit/tenant/resolve.test.ts` (ported from primitives' suite). Total repo-core: 265 → 279 tests.
973
-
974
- ### Added — schema-builder vocabulary
975
-
976
- - **`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.
977
- - **`SchemaBuilderOptions.response`** with `omitFields?: string[]` — response-schema overrides. Drops extra fields from the response shape without marking them globally hidden.
978
- - **`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.
979
- - **`FieldRule.hidden?: boolean`** — strips the field from the response shape. Distinct from `systemManaged` (request-body concern). Use for passwords, secrets, internal scoring.
980
- - **`collectFieldsToOmit(options, 'response')`** — third purpose alongside `'create'` / `'update'`. Implements the response policy (only `hidden` + `excludeFields` + `response.omitFields`).
981
-
982
- These are the contracts mongokit 3.12 implements, arc 2.12's MongooseAdapter consumes, and any future kit (sqlitekit, prismakit) inherits for free.
983
-
984
- ### Hardened — `RepositoryBase` plugin-shape validation
985
-
986
- `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:
987
-
988
- ```
989
- [repo-core] Repository "Foo": plugin at index 0 has wrong type.
990
- Expected a function or { name, apply(repo) } object — got string 'organizationId'.
991
- Common cause: `new Repository(Model, [tenantField], opts)` — second argument must be a plugins array.
992
- ```
993
-
994
- Lock-in: `tests/unit/repository/base-plugin-validation.test.ts` (6 cases covering string/null/object-without-apply/function/object/post-construction `use()` paths).
995
-
996
- ### Migration — mongokit 4.0, arc 2.12, arc-next 0.6
997
-
998
- Three downstream changes drop their local copies and import directly:
999
-
1000
- 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.)
1001
- 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.
1002
- 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.
1003
-
1004
- No breaking changes inside `@classytic/repo-core` itself — purely additive.
1005
-
1006
- ## [0.2.0] - 2026-04-22
1007
-
1008
- ### Added — Update IR (portable write-side primitive)
1009
-
1010
- - **New `@classytic/repo-core/update` subpath.** The write-side counterpart to `@classytic/repo-core/filter`. Plugins and arc's infrastructure stores compose an `UpdateSpec` once; each kit compiles it to its native shape.
1011
- - **Types:** `UpdateSpec` (tagged union on `op: 'update'`, four buckets: `set` / `unset` / `setOnInsert` / `inc`), `UpdateInput` (union of `UpdateSpec` | kit-native `Record<string, unknown>` | Mongo pipeline `Record<string, unknown>[]`).
1012
- - **Builders:** `update({ set, unset, setOnInsert, inc })` (root), `setFields`, `unsetFields(...f)`, `setOnInsertFields`, `incFields`, `combineUpdates(...specs)` (later-wins merge, `unset` de-duplicates).
1013
- - **Guards:** `isUpdateSpec` (routes portable IR to the compiler), `isUpdatePipeline` (lets SQL kits short-circuit with `UnsupportedOperationError`).
1014
- - **Compilers:** `compileUpdateSpecToMongo(spec)` emits `{ $set, $unset, $setOnInsert, $inc }`. `compileUpdateSpecToSql(spec)` emits a `SqlUpdatePlan` with `data` / `unset` / `inc` / `insertDefaults` buckets, leaving SQL generation (quoting, `ON CONFLICT`, parameter binding) to the kit.
1015
- - **`StandardRepo.findOneAndUpdate` + `updateMany` widened to `UpdateInput`.** Accepts all three forms — portable `UpdateSpec`, kit-native record, Mongo aggregation pipeline. Kits dispatch with `isUpdateSpec`. The existing raw-record and pipeline paths remain unchanged; the IR is purely additive so consumers don't need to migrate. Arc's infrastructure stores (outbox, idempotency, audit) will switch to the IR over a subsequent release to close the "Mongo-shaped store" gap flagged in the April 2026 cross-surface review.
1016
-
1017
- **Motivation (Arc April 2026 review):** arc's `EventOutbox`, `IdempotencyStore`, and `AuditStore` adapters use Mongo operator records (`$set`, `$inc`, `$unset`, `$setOnInsert`, `$or`, `$lte`, ...) directly against `RepositoryLike.findOneAndUpdate`. That works on mongokit but fails on sqlitekit — whose `findOneAndUpdate` treats `data` as flat column overwrites and would literally set a column named `$set`. The Update IR closes the gap without forcing every kit to ship its own Mongo-operator compatibility layer.
1018
-
1019
- **Rationale for scope:** the IR covers the subset every backend supports (atomic set / unset / inc / insert-default). Kit-native features — Mongo `$push`/`$pull`/`$addToSet`, aggregation pipeline updates, Postgres `jsonb_set`, SQL `CASE` expressions — stay on the kit-native path via `UpdateInput`'s raw-record and pipeline forms. No lowest-common-denominator bloat; no feature loss for kits that already offer more.
1020
-
1021
- **Test delta**: 193 → 230 tests (37 new across `tests/unit/update/builders`, `/guard`, `/compile`).
1022
-
1023
- ### Changed — breaking: `StandardRepo` write signatures
1024
-
1025
- `StandardRepo.findOneAndUpdate(filter, update, ...)` and `updateMany(filter, data, ...)` — the second parameter is now typed `UpdateInput` (was `Record<string, unknown> | Record<string, unknown>[]` and `Record<string, unknown>` respectively). Every call site that compiled against 0.1.0 keeps compiling: `Record<string, unknown>` and `Record<string, unknown>[]` are subtypes of `UpdateInput`. The break is on the **implementer** side — any kit that declared only the old parameter type no longer structurally satisfies `StandardRepo` under strict contravariance. mongokit 3.11.0 and sqlitekit 0.1.1 already ship with the widened signatures; third-party kits need to widen before bumping their `@classytic/repo-core` peer dep.
1026
-
1027
- ### Changed — breaking: `updateMany` + `deleteMany` promoted to required members of `StandardRepo`
1028
-
1029
- Both methods were optional (`updateMany?` / `deleteMany?`) in 0.1.0; they're required in 0.2.0. Rationale: every real backend has a native bulk-update and bulk-delete primitive, and arc's infrastructure stores (outbox, idempotency, audit cleanup) assume both are callable without feature-detection. Leaving them optional invited the "forgot to wire `batchOperationsPlugin`" runtime `TypeError` footgun that earlier releases of mongokit shipped — with the promotion, the type system catches missing implementations at the kit boundary.
1030
-
1031
- **Impact**:
1032
- - **Kits**: any kit declaring `class FooRepo<T> implements StandardRepo<T>` must provide `updateMany` and `deleteMany` or fail to compile. mongokit 3.11.0 and sqlitekit 0.1.1 both ship these as class primitives, so their conformance stays green.
1033
- - **Consumers of `RepositoryLike<T> = MinimalRepo<T> & Partial<StandardRepo<T>>`** (arc's pattern) are unaffected — `Partial` reimposes optionality for feature detection at the arc adapter boundary. `if (repo.updateMany)` guards keep working.
1034
- - `bulkWrite` stays optional — the mongoose-shaped `BulkWriteOperation` has no clean SQL analogue, and every kit would ship an uninteresting fan-out wrapper otherwise.
1035
-
1036
- ### Naming — `UpdateInput` collision with mongokit
1037
-
1038
- `@classytic/repo-core/update` exports `UpdateInput` as the union `UpdateSpec | Record<string, unknown> | Record<string, unknown>[]`. `@classytic/mongokit` currently **also** exports a type named `UpdateInput<TDoc> = Partial<Omit<TDoc, '_id' | 'createdAt' | '__v'>>` — a completely different, document-typed shape used by `repo.update(id, data)`. If you write `import { UpdateInput } from '@classytic/mongokit'`, you get mongokit's generic; if you write `import type { UpdateInput } from '@classytic/repo-core/update'`, you get the union. Both names may appear in the same consumer file — import at least one with an alias (`import type { UpdateInput as UpdatePatch } from '@classytic/mongokit'`). mongokit will rename its local type in a follow-up release to close the collision permanently.
1039
-
1040
- ### Added
1041
- - Phase 0 scaffold: package.json, tsconfig, tsdown, biome, vitest (4-tier), knip
1042
- - `@classytic/repo-core/hooks` — `HOOK_PRIORITY` constants + `HookPriority` type
1043
- - `@classytic/repo-core/operations` — `CORE_OP_REGISTRY`, `extendRegistry`, `listOperations`, `mutatingOperations`, `readOperations`, `operationsByPolicyKey`, `describe`; 17 core ops, driver-free
1044
- - `@classytic/repo-core/errors` — `HttpError`, `createError`, `isHttpError`, `conservativeMongoIsDuplicateKey`, `toDuplicateKeyHttpError`
1045
- - `@classytic/repo-core/pagination` — `encodeCursor`/`decodeCursor` (URL-safe base64, mongokit ≤3.x compat), `validateCursorSort`, `validateCursorVersion`, `normalizeSort`, `validateKeysetSort`, `invertSort`, `getPrimaryField`, `validateLimit`, `validatePage`, `shouldWarnDeepPagination`, `calculateSkip`, `calculateTotalPages`
1046
- - INFRA.md tracking doc + 4-tier test structure per monorepo testing-infrastructure.md
1047
- - 74 unit tests across 6 files, full suite runs in ~360 ms
1048
- - `@classytic/repo-core/filter` — Filter IR types (`Filter` discriminated union), combinators (`eq`, `ne`, `gt`/`gte`/`lt`/`lte`, `in_`/`anyOf`, `nin`/`noneOf`, `like`, `regex`, `exists`, `and`, `or`, `not`/`invert`), constants (`TRUE`, `FALSE`), runtime guard (`isFilter`), traversal (`walkFilter`, `mapFilter`, `collectFields`), in-memory evaluator (`matchFilter`, `asPredicate`). Boolean absorbing/identity elimination baked in.
1049
- - `@classytic/repo-core/repository` — arc-aligned contract types: `MinimalRepo<TDoc>` (5-method floor), `StandardRepo<TDoc>` (recommended surface), option/result types (`QueryOptions`, `WriteOptions`, `DeleteOptions`, `FindOneAndUpdateOptions`, `DeleteResult`, `DeleteManyResult`, `UpdateManyResult`, `PaginationParams`), `RepositorySession`, `InferDoc<R>`.
1050
- - 43 additional unit tests (builders, walk, match) bringing total to 117.
1051
- - **Filter IR expansion** — sugar builders: `between`, `startsWith`, `endsWith`, `contains`, `iEq`, `isNull`, `isNotNull` (desugar to existing ops); new `raw` escape-hatch op for driver-native fragments (e.g. pgvector `<=>`, SQLite JSON1 path, Mongo `$geoWithin`).
1052
- - `@classytic/repo-core/query-parser` — URL → `ParsedQuery<Filter>` driver-agnostic parser. Bracket grammar (`field[gte]=18`, `field[in]=a,b`, `field[contains]=text`). Shared types (`ParsedQuery`, `ParsedSort`, `ParsedSelect`, `ParsedPopulate`, `QueryParserOptions`, `BracketOperator`) consumed identically by every kit and both frontends (arc-next, fluid).
1053
- - 36 more unit tests covering sugar builders + QueryParser. Total: **153 tests, 11 files, green on vitest 4**.
1054
- - Upgraded to **vitest 4.1**, **typescript 6.0**, **biome 2.4.12**. Vitest config migrated to new `pool` / `fileParallelism` top-level API.
1055
- - **M3 landed: `RepositoryBase` abstract class** at `@classytic/repo-core/repository`. Owns `HookEngine`, plugin installation, context builder, `_emitAfter` / `_emitError`, and the `_cachedValue` cache-short-circuit helper. Kits extend it instead of reinventing the hook layer.
1056
- - **M4 landed: 5 portable plugins** — `@classytic/repo-core/plugins/timestamp`, `/multi-tenant`, `/soft-delete`, `/audit`, `/cache`. All Filter-IR-native. Multi-tenant + soft-delete inject scope via `and(existing, eq(...))` / `and(existing, isNull(...))` — no Mongo-specific `$`-operators. Cache includes pluggable `CacheAdapter` interface + reference `createMemoryCacheAdapter`.
1057
- - **Tightened the scope rule for plugins** — skipped observability / validation-chain / field-filter / cascade / custom-id. Rule: a plugin lives in repo-core only if every backend needs it AND no backend provides it natively. The skipped ones are either arc's job (field-filter), host cookbook (observability, custom-id), zod's job (validation-chain), or SQL-native (cascade).
1058
- - **`StandardRepo.withTransaction` signature fixed** — was `(session) => T`, mongoose leak. Now `(txRepo: this) => T` — caller writes `await txRepo.create(...)`, never touches session. SQL / Prisma kits bind the tx connection to a new repo; mongokit returns a session-threaded proxy.
1059
- - `QueryOptions.session` re-documented as mongoose-specific.
1060
- - `RepositoryBase.on` / `off` now generic over listener-data type so typed plugins (`(ctx: RepositoryContext) => void`) don't need casts.
1061
- - **HookEngine** exposed at `@classytic/repo-core/hooks` — `DEFAULT_LISTENER_PRIORITY`, `HookEngine` class, `HookListener`, `HookMode`, `PrioritizedHook`, `EventPhase` types.
1062
- - **Context subpath** at `@classytic/repo-core/context` — `RepositoryContext` type exposed independently for plugin authors.
1063
- - **`HookEngine.listeners()`** — read-only snapshot of the listener registry (frozen buckets). Lets kits expose a back-compat `_hooks: Map<event, PrioritizedHook[]>` getter without handing out mutable internal state. Used by mongokit 3.10's read-through `_hooks` shim.
1064
- - **`RepositoryBase._buildContext` always awaits `before:*` hooks** regardless of engine mode — policy plugins must mutate context synchronously before the driver call fires. After- and error-hooks still honor `hooks: 'sync'` for fire-and-forget observability. Previous behavior (fire-and-forget in sync mode) would let driver calls race ahead of tenant/soft-delete scope injection; tracked as a latent bug surfaced by the mongokit 3.10 migration.
1065
-
1066
- ### Changed — plugins move out, primitives move in
1067
-
1068
- - **All 5 plugins deleted from `@classytic/repo-core/plugins/*`** — `timestamp`, `soft-delete`, `multi-tenant`, `audit`, `cache` no longer ship from this package. Rationale: end-users install a kit, never repo-core directly, so plugins belong in the namespace users actually import. Each kit (mongokit, sqlitekit, pgkit, prismakit) owns its own plugin implementations, which lets each use driver-native features (mongoose native timestamps, SQLite triggers, Postgres `now()`, Prisma `@default`) instead of a lowest-common-denominator JS emulation.
1069
- - **New `@classytic/repo-core/cache` subpath** — the plugin-composition primitives that every kit's cachePlugin shares:
1070
- - `CacheAdapter` interface (get / set / del / delByPattern)
1071
- - `stableStringify` — deterministic JSON keying for cache buckets
1072
- - `createMemoryCacheAdapter` — reference in-memory adapter for tests + single-process apps
1073
- - **New scope helpers in `@classytic/repo-core/filter`** — `buildTenantScope(existing, tenantField, tenantId)` and `mergeScope(existing, scope)`. Every policy plugin (multi-tenant / soft-delete / org-boundary) AND-s a predicate into an existing filter while handling three shapes (undefined / Filter IR / flat record). Lifted into `/filter` so each kit's plugin composes the same merge semantics.
1074
- - **Architectural principle codified** — "Repo-core is invisible infrastructure. End-users install a kit; repo-core exists for kit authors." See INFRA.md §3.
1075
-
1076
- ### Arc integration review — 2026-04-19
1077
-
1078
- Changes landed in response to the Arc 2.10 maintainer's end-to-end integration report (see `ARC_INTEGRATION_REPORT.md`). All three non-blocking enhancements adopted; sqlitekit-side blockers are tracked on the sqlitekit package, not here.
1079
-
1080
- - **`OffsetPaginationResult<TDoc, TExtra>` + `KeysetPaginationResult<TDoc, TExtra>`** — added an optional second generic so kits can surface typed extras alongside the core envelope (mongokit emits `warning?: string` on deep-page reads, pgkit could surface `queryPlan`, sqlitekit could surface vacuum hints). Defaults to `Record<string, never>` so existing consumers (`OffsetPaginationResult<User>`) see zero behavioral change. Both types also export their core interface (`OffsetPaginationResultCore` / `KeysetPaginationResultCore`) for StandardRepo contract references. The `method` discriminant carries through the intersection so `if (result.method === 'offset')` narrowing keeps working.
1081
- - **`HOOK_EVENTS` constant + `HookEventName` type** at `@classytic/repo-core/hooks` — canonical string-constant registry for every `before:* / after:* / error:*` event across the MinimalRepo + StandardRepo op set (17 ops × 3 phases = 51 events). Plugin authors subscribe via `HOOK_EVENTS.BEFORE_CREATE` instead of raw strings so typos become compile errors instead of silent no-ops. Kits with additional native ops (mongokit's `aggregate` / `bulkWrite`) compose their own extended constant on top. Cross-kit plugins written against `HOOK_EVENTS` work identically on every kit for the shared op set.
1082
- - **`CacheAdapter.clear?(pattern?)`** replaces the previous `delByPattern?(pattern)` — aligns with mongokit's `CacheAdapter` and Arc's `CacheStore` so one Redis / KV / Memcached implementation plugs into every consumer. `clear()` with no argument wipes everything; `clear('prefix:*')` is glob-matched. `createMemoryCacheAdapter` reference impl updated. Breaking change for anything calling `adapter.delByPattern` directly — sqlitekit's cache plugin (the only in-tree caller) migrated in the same commit.
1083
- - **`CacheAdapter.delete(key)`** — renamed from `del(key)` for consistency with JavaScript's native `Map.delete` / `Set.delete`, `MinimalRepo.delete(id)` in this same package, arc's `RepositoryLike.delete`, and every higher-level cache library (Keyv, etc.). Redis clients keep their own `.del()` — the adapter implementation translates. Applied across `@classytic/repo-core/cache` (interface + `createMemoryCacheAdapter`) and `@classytic/mongokit` (its own `CacheAdapter` type + `createMemoryCache` reference impl + every test adapter). The rename arrived **before first publish**, so no migration cost for external consumers — it's simply the shipping name.
1084
- - **TTL unit stays seconds** (`ttlSeconds`) — matches Redis `SET EX seconds` and mongokit's existing `ttl` semantics. Arc's `ttlMs` was the outlier; the Arc team agreed to align on seconds.
1085
- - **Sync-or-async return types preserved** on `CacheAdapter` — memory-backed adapters return synchronously without a microtask hop; Redis adapters return Promises. Consumers `await` either way at no runtime cost.
1086
-
1087
- **Test delta**: 153 → 177 tests (24 new covering HOOK_EVENTS exhaustiveness, pagination TExtra narrowing, and CacheAdapter + stableStringify round-trip).
1088
-
1089
- ### Unified `withTransaction` contract — mongokit caught up
1090
-
1091
- The `StandardRepo.withTransaction` contract at `@classytic/repo-core/repository` has always specified `fn: (txRepo: this) => Promise<T>` — the bound-tx shape sqlitekit has implemented since 0.1. Mongokit 3.9 deviated from the contract and passed a raw `ClientSession` instead. Mongokit 3.10 fixes that: its `withTransaction` now hands over a session-threaded proxy repository matching the canonical signature. Cross-kit plugins and apps that depend on `StandardRepo.withTransaction` now work identically against mongokit and sqlitekit — one contract, no kit-specific branches.
1092
-
1093
- The contract docstring in `src/repository/types.ts` was already accurate; this is the mongokit-side implementation catching up. See `@classytic/mongokit` 3.10 release notes for the migration diff — this is a **breaking change for mongokit 3.x users** but repo-core's shape is unchanged.
1094
-
1095
- ### Consumed by
1096
-
1097
- - **`@classytic/mongokit`** — `Repository extends RepositoryBase`; hook engine, plugin-order validator, and `HOOK_PRIORITY` sourced from repo-core. Ships its own Mongo-optimized plugins (unchanged from 3.9).
1098
- - **`@classytic/sqlitekit`** — extends `RepositoryBase`; ships its own SQLite-optimized plugins at `@classytic/sqlitekit/plugins/{timestamp,soft-delete,multi-tenant,audit,cache}`. Imports `CacheAdapter` + `stableStringify` + `buildTenantScope` from repo-core.
1
+ # Changelog
2
+
3
+ All notable changes to `@classytic/repo-core` are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.30.0] - 2026-10-06
8
+
9
+ ### Added — command streams and a shared catalog in `./sync`
10
+
11
+ - **`CommandStreamStore`**: the server side of a command-sync protocol. It records one verdict per
12
+ sequence number (carrying the command's `fingerprint`) and a per-stream watermark bound to a
13
+ tenant, all in the transaction of the command's effects. `createMemoryCommandStreamStore` is
14
+ the reference implementation; mongokit ships the durable one.
15
+ - **Resolutions and aliases** on the same contract:
16
+ - outcome `resolved`;
17
+ - a refused verdict keeps its `body` and `dependsOn`;
18
+ - `resolution` attribution;
19
+ - `record` replaces;
20
+ - `blockedOn(commandId)`;
21
+ - `alias` / `aliasOf`: tenant-scoped, and one local id names one record.
22
+ - **`runCommandStreamStoreConformance`** (`./testing`): eight cases. One of them is serialisation:
23
+ twenty concurrent advances of one stream must reach 20.
24
+ - **`runChangeLogStoreConformance`** (`./testing`): eleven cases. These include commit order: an
25
+ entry that commits after a reader's checkpoint must still reach that reader.
26
+ - **`ChangesSinceOptions.sharedScopes`**: company-wide scopes (a catalog) that every tenant reads,
27
+ whatever tenant their entries carry.
28
+
29
+ ### Removed
30
+
31
+ - `PushMutation`, `PushVerdict`, `PushVerdictStatus`. Nothing consumed them; command streams
32
+ replace them.
33
+
34
+ ## [0.29.0] - 2026-09-23
35
+
36
+ ### ⚠ Changed — ONE list-query grammar, held to by every parser (`./query-parser`)
37
+
38
+ The URL grammar is now an explicit contract in repo-core, and arc's `ArcQueryParser` and
39
+ mongokit's `QueryParser` read requests through it, so a query means the same thing everywhere.
40
+ Before, the three parsers disagreed on 32 of 43 probed query strings. A few disagreements were
41
+ security holes (`$where` passing through `parseUrl`, malformed keys dropped), and some silently
42
+ returned wrong rows (date ranges compared as strings, `007` read as `7`).
43
+
44
+ - **`readFilterClauses` / `clausesToFilter`**: keys are `field` or `field[op]` (a dot path).
45
+ Operators are closed: `eq ne gt gte lt lte in nin exists contains startsWith endsWith ieq
46
+ regex`, plus `like` (= `contains`), `between=a,b` and `options` (regex flags). A kit adds its
47
+ own via `extensionOperators`.
48
+ - Text operators match LITERAL text case-insensitively; only `regex` is a pattern, and an unsafe
49
+ one is refused.
50
+ - `ne` / `nin` / `exists` follow the IR: null and missing never match a value.
51
+ - Membership is a union: `status=a&status=b`, repeated `[in]` and `eq`+`in` become one `in`.
52
+ - An empty value is no filter.
53
+ - **`coerceQueryValue`** replaces `coerceValue` / `coerceList`. A declared type is exact, and a
54
+ value that doesn't fit is refused. Without one: `null`, `true`/`false`, safe numbers without
55
+ leading zeros (15 chars max for equality, any length for a range bound), and ISO dates for
56
+ range operands only.
57
+ - **`readPageRequest` / `readSort` / `readSelect` / `readSearch`**:
58
+ - `limit` and `page` are positive integers; a limit over the cap is clamped.
59
+ - An invalid or disallowed sort/select field is refused, and an over-long search is refused,
60
+ never truncated. Sort/select accept commas or Mongoose's spaces.
61
+ - **`QueryGrammarError`** (400, `INVALID_QUERY_INPUT`, `validationErrors[0].path`) carries a
62
+ `kind` of `syntax` | `policy` | `value`, so a lenient parser can relax allowlists without
63
+ tolerating smuggled operators. `onInvalid` is drop mode, and structural refusals reach it too.
64
+ - **`parseUrl`** is rebuilt on the grammar: allowlists refuse rather than drop, populate `match`
65
+ is field equality only, and `maxFilterDepth` is removed (the flat grammar has no nesting to
66
+ bound). The default `maxLimit` is 1000, matching arc and mongokit.
67
+ - **`filter`** is a reserved param (the envelope).
68
+
69
+ ### Added — `runQueryGrammarConformance` (`./testing`)
70
+
71
+ A semantic suite: one fixture (nulls, missing fields, codes, dates, `.` and `%` in text) and
72
+ every case must select the same rows or be refused with a 400. arc and mongokit run it, mongokit
73
+ also against a real MongoDB, and sqlitekit against a real SQLite. A new parser passes by agreeing
74
+ on RESULTS. `unsupportedOperators` lets a backend that cannot run an operator (SQLite without
75
+ REGEXP) be held to REFUSING it with a 400 instead.
76
+
77
+ ### Fixed — the in-memory matcher disagreed with every kit's compiler
78
+
79
+ - `matchFilter`: `ne` / `nin` now exclude null and missing (SQL three-valued logic, pinned by
80
+ `runStandardRepoConformance`), and `eq null` matches missing as well as null. Before, an
81
+ in-memory policy check could admit a row the same filter excluded in the database. `in` with a
82
+ `null` member likewise matches a missing field (IS NULL), as Mongo and SQL do.
83
+ - `matchFilter`'s LIKE now honours the `\` escape every kit uses, so `contains('note', '50%')`
84
+ matches the literal `%`. `escapeLikePattern` escapes `\` itself.
85
+ - `policyRecordToFilter` keeps the Mongo record dialect's own meaning: `$ne: v` becomes
86
+ `not(eq)` (a missing field matches, as in Mongo). A `$nin` listing `null` becomes the strict
87
+ IR `nin`, which compiles back to a single `$nin`.
88
+
89
+
90
+ ### Added — the wire error-code catalog (`./errors`)
91
+
92
+ `ARC_ERROR_CODES` and `ARC_REASON_CODES` join `ERROR_CODES` in `src/errors/codes.ts`: every code
93
+ arc puts on `ErrorContract.code`, owned by the contract package so a client SDK reads the wire
94
+ vocabulary without depending on the server. Arc emits from these constants and its test suite
95
+ fails on an uncatalogued `arc.*` literal. `DUPLICATE_KEY_DETAIL_CODE` names the `details[].code`
96
+ `toErrorContract` already emitted. `ERROR_CODES` moved out of `types.ts` (same export path).
97
+
98
+ ### Added — `assessRegex` / `escapeRegex` (`./query-parser`): the one ReDoS analyser
99
+
100
+ Structural, not a regex over the regex: the pattern is walked with a group stack, so a repeated
101
+ group containing a VARIABLE repetition or an alternation — `(a+)+`, `(a*)*`, `(.*)*`, `(a|aa)+` —
102
+ is refused however it is spelled, as are backreferences, more than 20 unbounded quantifiers, a
103
+ complexity budget (more than 8 groups, more than 10 alternations, quantifiers × groups over 40)
104
+ and invalid syntax. `(a{2})+` is allowed (a fixed count is unambiguous). `parseUrl` drops an
105
+ unsafe `regex` leaf; mongokit's and arc's parsers now use it instead of their own heuristics,
106
+ which both missed `(a*)*`.
107
+
108
+ ### Fixed — invalidation could be undone by timing
109
+
110
+ - `createTtlMemo`: a load in flight when `invalidate()` ran wrote its superseded result back, and a
111
+ `get` after the invalidation could join that stale load. Slots now carry a generation, so an old
112
+ load writes where nothing reads.
113
+ - `addToSet` kept a set's CREATION expiry (`NX`), so a tag index could expire while newer tagged
114
+ entries lived on and tag invalidation missed them. The contract is now `GT` — extend, never
115
+ shorten (Redis ≥ 7: `EXPIRE key ttl GT`) — and the GET/SET fallback refreshes a re-cached
116
+ entry's index too.
117
+
118
+ Pinned in `tests/unit/cache/invalidation-races.test.ts`.
119
+
120
+ ### ⚠ Fixed — `stableStringify` / `contentHash` collided on dates and regexes
121
+
122
+ Every `Date` serialised as `{}` (it has no enumerable keys), so two payloads differing only by a
123
+ date had the SAME `contentHash` — contradicting its own contract — and every `RegExp` collided the
124
+ same way in cache keys. It now has wire semantics: `toJSON` applied (a `Date` is its ISO string, an
125
+ ObjectId its hex), `undefined` absent, a `RegExp` as `{"$regex","$options"}`, and keys ordered by
126
+ code unit instead of `localeCompare`. `fingerprintRequest` already normalised its input and is
127
+ unchanged. Digests of values holding dates, regexes or ObjectIds change once.
128
+
129
+ ### Fixed — single-flight could hand one scope's result to another
130
+
131
+ `CacheEngine`'s in-flight map was keyed by cache-key alone while the STORE was resolved per scope
132
+ (`adapter: () => requestScopedCache()`), so two concurrent reads of one key in different scopes
133
+ coalesced and the second received the first's result. In-flight fetches are now partitioned by
134
+ the resolved adapter, and a read with no store in scope (or bypassed) never joins another
135
+ caller's fetch. Dedup within one scope is unchanged. Pinned in
136
+ `tests/unit/cache/single-flight-scope.test.ts`.
137
+
138
+ ### Added — `@classytic/repo-core/idempotency`: run a command at most once per key
139
+
140
+ The one claim contract for every layer, beside `./lock` for the same reason: the store
141
+ contract is driver-free and every kit already depends on this package. Kernels, spine modules,
142
+ arc and hosts had kept at least four claims tables, each re-deriving the decision.
143
+
144
+ - `runIdempotent({ store, identity, requestFingerprint, execute, … })` — claim → execute →
145
+ complete. Outcomes `executed | replayed | in_flight | exhausted`; the same key with a
146
+ different body throws `FINGERPRINT_MISMATCH`; a terminal failure replays as
147
+ `IdempotentReplayError`. `execute` gets `{ progress, context, checkpoint }` — recovery points
148
+ a takeover resumes from; `checkpoint(progress, { session })` commits one inside the side
149
+ effect's own transaction. `classifyFailure` → `terminal | transient | ambiguous` (ambiguous
150
+ keeps the claim and lapses its lease now). `maxAttempts` → `exhausted`. `storeOptions.session`
151
+ joins the caller's transaction.
152
+ - `IdempotencyClaimStore` — `get`, `insert` (iff absent), `swap` / `release` (iff the in-flight
153
+ lease matches), `listLapsed` (for sweeps). `createMemoryIdempotencyStore()` is the reference.
154
+ - `runIdempotencyStoreConformance` / `idempotencyStoreCases` (`./testing`) — every kit's store
155
+ runs it; its concurrency cases reject a read-then-write store.
156
+ - `decideClaim`, `identityKey` (code-point ordered — never locale-sensitive), `fingerprintRequest`
157
+ (`contentHash` of the wire form; `undefined` dropped, `toJSON` honoured, refuses past 100 levels
158
+ with `UNFINGERPRINTABLE`).
159
+
160
+ ### Added — `KeysetPaginationResultCore.end`
161
+
162
+ The cursor at a page's LAST row, minted whether or not more rows exist. Feed it back as
163
+ `after` to resume later: a change feed that walked to its end (`next: null`) continues from
164
+ `end` and reads only what changed since, instead of re-reading everything. Optional —
165
+ absent means the kit does not mint it.
166
+
167
+ ### Fixed — `fieldRules` value constraints on ARRAY fields
168
+
169
+ `mergeFieldRuleConstraints` wrote `enum`, `pattern`, `minLength`, `maxLength`,
170
+ `min` and `max` onto the property regardless of its type. On an array property
171
+ that is wrong in two directions, and neither one throws:
172
+
173
+ - **`enum` refused every write.** It compares the WHOLE array to each member, and
174
+ a list of strings never equals a string. A body that omitted the field was
175
+ refused too, with an error naming a field the caller never sent. This made an
176
+ admin settings screen unable to save a single row.
177
+ - **`pattern` / `minLength` / `maxLength` / `min` / `max` validated nothing.**
178
+ They are string and number keywords, and JSON Schema ignores them on an array —
179
+ so a rule read as enforced while checking nothing. A typed-in email address was
180
+ never validated at the API.
181
+
182
+ Value constraints now land on the ELEMENT schema of an array (every position of a
183
+ tuple `items`, every array branch of an `anyOf`), and an array with no `items`
184
+ gets one so the constraint is never dropped. `description` and `nullable` still
185
+ describe the field itself: a nullable array may be `null`, its elements may not.
186
+
187
+ `min` / `max` bound each element's VALUE, not the item count — one meaning per
188
+ keyword, rather than one that depends on a type the author never wrote.
189
+
190
+ **Behaviour change for every kit** (mongokit, sqlitekit, pgkit, mysqlkit): array
191
+ fields carrying a `pattern`/`minLength`/`maxLength`/`min`/`max` rule are now
192
+ actually enforced, so a write that previously slipped through may be refused.
193
+ That write was always invalid by the rule's own definition.
194
+
195
+ This function had no tests. It now has 16, compiled against a validator rather
196
+ than compared as shapes — a schema can look right and still accept what it
197
+ exists to refuse.
198
+
199
+ ## [0.28.0] - 2026-09-14
200
+
201
+ ### Changed — BREAKING: `RetryPolicy.shouldRetry` no longer defaults to retrying everything
202
+
203
+ `shouldRetry` defaulted to `() => true`, with a comment conceding it was unsafe
204
+ and asking callers to pass their own. That is a documented hazard, not a fence:
205
+ a policy added for network blips also re-ran the write on a duplicate key, a
206
+ validation failure and a permission denial — three times, with backoff. Against
207
+ a slow-but-succeeding server and a non-idempotent write, that is a double write.
208
+
209
+ The default is now **`isTransientError`** (exported from `./repository`): retry
210
+ a failure that MAY not have happened — failover, write conflict, lock timeout,
211
+ socket error — never one the server answered deterministically. A 4xx-shaped
212
+ `statusCode` is treated as final, since `HttpError` carries one and every kit
213
+ maps validation and permission failures onto it.
214
+
215
+ **Migration:** a caller relying on retry-everything passes `shouldRetry: () => true`
216
+ explicitly. The point is that it is now a decision rather than an inheritance.
217
+ Tests exercising retry MECHANICS should pass a predicate too, so they stop
218
+ depending on how a generic `Error` happens to be classified.
219
+
220
+ ### Added
221
+
222
+ - **`resumeToken` on `ChangeEvent`, `startAfter` on `WatchOptions`.** `watch()`
223
+ already accepted a resume token but a consumer had no way to obtain one, so
224
+ the documented at-least-once-across-restarts contract was unfulfillable.
225
+ Persist the last event's `resumeToken` and hand it back as `resumeAfter` (or
226
+ `startAfter`, which also resumes past an invalidate event) to continue from
227
+ that point. Additive — a kit whose feed is not resumable leaves the field
228
+ absent.
229
+
230
+ - **`AggRequest.countDistinctStrategy`** — `'accumulator'` (default) or
231
+ `'grouped'`. The default holds every distinct value of a group in that
232
+ group's accumulator, so memory grows with the data and MongoDB caps it at
233
+ 100MB; `'grouped'` pre-groups by the distinct field so each value becomes a
234
+ row instead, and nothing accumulates. Results are identical — including that
235
+ a missing field is not a distinct value while an explicit `null` is. Kits
236
+ refuse the measure combinations they cannot recombine (`avg`, `percentile`,
237
+ `stddev`, a second `countDistinct`) rather than approximating them.
238
+
239
+ - **`prev` / `hasPrev` on `KeysetPaginationResultCore`** — the backward half of
240
+ cursor pagination, for kits that implement a `before` cursor (mongokit does,
241
+ >= 3.39.0). Both are OPTIONAL, so a kit that only pages forward is unchanged.
242
+
243
+ `prev === undefined` means *"this kit does not answer the question"*, NOT
244
+ *"there is no previous page"*. A UI must read `hasPrev` to decide whether to
245
+ offer the control and treat absent as unknown — reading `undefined` as `false`
246
+ hides a control that should be there.
247
+
248
+ ### Changed
249
+
250
+ - **`validateKeysetSort` accepts mixed sort directions.** `{ priority: 1,
251
+ createdAt: -1 }` — the ESR-shaped compound index — used to throw. The keyset
252
+ predicate is a tuple comparison whose operator is chosen per position, so the
253
+ directions never needed to agree; rejecting them pushed callers onto offset
254
+ `skip(n)`, the cost keyset exists to avoid. `_id` now keeps the direction the
255
+ caller gave it, and still follows the primary field when absent.
256
+
257
+ **A kit's filter builder must read the direction PER FIELD** to consume this
258
+ safely. mongokit's `buildKeysetFilter` does (>= 3.39.0). A kit that derives
259
+ one operator from the primary field will now silently mis-paginate a
260
+ mixed-direction sort instead of being protected by the throw — audit yours
261
+ before upgrading.
262
+
263
+ ### Fixed
264
+
265
+ - **A cached `getAll` page was served to a caller that asked for a different
266
+ count strategy.** `countStrategy` / `countLimit` were missing from
267
+ `DEFAULT_SHAPE_KEYS_BY_OP.getAll`, so every strategy shared one cache key —
268
+ and the strategies disagree about `total` BY DESIGN: `'none'` reports `0`,
269
+ `'capped'` reports a flagged ceiling, `'exact'` reports the truth. A list
270
+ asking for an exact count could therefore render `0 results` over a full page
271
+ of rows, with nothing reporting an error. Both keys are now on the allowlist.
272
+
273
+ `mode` remains off it deliberately — it selects which ENVELOPE comes back, and
274
+ offset vs keyset are already separated by `page` vs `after`.
275
+
276
+ **On deploy this invalidates existing `getAll` cache entries once** (the key
277
+ shape changed); the next read repopulates them.
278
+
279
+ Pinned by mongokit's `plugin-composition-security.test.ts`, whose
280
+ `countStrategy` case had been `it.skip`ped with a comment documenting the hole
281
+ rather than closing it.
282
+
283
+ ## [0.27.1] - 2026-09-11
284
+
285
+ ### Fixed
286
+
287
+ - **A failed single-flight fetch with no concurrent waiter raised an
288
+ `unhandledRejection`** — in addition to the error the caller had already
289
+ caught. `claimPending` creates a deferred for waiters that may never arrive;
290
+ rejecting it for nobody made a working `try/catch` a process crash under
291
+ `--unhandled-rejections=strict`. Reached from `CacheEngine.prefetch`, so from
292
+ every `createTtlMemo` consumer, and from the plugin read path. The deferred is
293
+ now marked observed at creation; a waiter that does exist still receives the
294
+ rejection. `tests/unit/cache/single-flight-unobserved-rejection.test.ts` pins
295
+ the single-caller case the existing two-caller test could never reach.
296
+
297
+ ## [0.27.0] - 2026-09-09
298
+
299
+ ### Added
300
+
301
+ - **`createTtlMemo(load, { ttlMs, keyOf?, maxEntries? })`** on `./cache` — the
302
+ one way to memoise a keyed async load for a while. It is `CacheEngine.prefetch`
303
+ over the memory adapter, so concurrent misses for one key share ONE load, a
304
+ thrown load caches nothing, and a `null` result is an answer.
305
+
306
+ Four packages had each hand-rolled `Map<key, { at, value }>` with a TTL, and
307
+ every one wrote its entry AFTER the await. Under parallel event dispatch N
308
+ callers reach the await together, all miss, and the cache only ever helps the
309
+ caller after them — a 30 s TTL that measured nine reads of one row per event.
310
+ `tests/unit/cache/ttl-memo.test.ts` pins single flight, expiry, invalidation,
311
+ `ttlMs: 0` = off, and refuses an object key without `keyOf` (an
312
+ identity-compared key never hits and reads as a caching bug).
313
+
314
+ ## [0.26.1] - 2026-09-08
315
+
316
+ ### Fixed
317
+
318
+ - **`vitest` is no longer declared as a peer dependency.** It was
319
+ `peerDependencies: { vitest: "^3.0.0 || ^4.0.0" }` with
320
+ `peerDependenciesMeta.optional: true` — and that combination does not do what
321
+ it looks like. `optional: true` only silences the *missing* case; npm still
322
+ hard-fails `ERESOLVE` on a version **mismatch**. So every consumer on a newer
323
+ vitest could not install repo-core at all, even though nothing they imported
324
+ touched it.
325
+
326
+ Measured: `@classytic/arc` could not move to vitest 5 — `npm install` failed
327
+ with `Conflicting peer dependency: vitest@5.0.0`, sourced to this package.
328
+ Widening the range to `|| ^5.0.0` would only have moved the wall to vitest 6.
329
+
330
+ `vitest` is the CONSUMER's test runner. Anyone importing
331
+ `@classytic/repo-core/testing` is by definition already inside a vitest suite
332
+ that provides it, so the import resolves from their install; anyone who does
333
+ not import it should never have been constrained. It stays in
334
+ `devDependencies` for this package's own suite, and `tsdown`'s `neverBundle`
335
+ now lists `vitest` so the bundler keeps the import external instead of
336
+ inlining a dev-only dependency into shipped output.
337
+
338
+ Any vitest exposing the standard `describe` / `it` / `expect` / `beforeEach` /
339
+ `afterEach` API works with the `/testing` conformance suites.
340
+
341
+ ## [0.26.0] - 2026-09-02
342
+
343
+ ### Added
344
+
345
+ - **`@classytic/repo-core/loader` — `createBatchLoader`.** Read-side counterpart
346
+ of a kit's `bulkWrite`: keys issued anywhere in one tick coalesce into a single
347
+ `batch()` call, so a per-item read in a loop costs one round trip instead of N.
348
+ Create one per operation — it caches, so its lifetime is a correctness
349
+ property and the caller owns it.
350
+
351
+ Refuses rather than guesses, in three places that otherwise return a plausible
352
+ wrong answer with nothing reported:
353
+ - a `batch()` result whose length differs from the keys (a short array shifts
354
+ every later value onto the wrong key, and each value is individually valid);
355
+ - an object key with no `keyOf` (`String({})` is `[object Object]` for every
356
+ object, so the default would merge unrelated keys AND report the collisions
357
+ as cache hits);
358
+ - a non-integer or `< 1` `maxBatchSize` (`Math.max(1, NaN)` is `NaN`, and a
359
+ `NaN` stride abandons every waiter with no error) — same validation
360
+ `repository/purge.ts` already applies to this parameter.
361
+
362
+ Known limits: chunks beyond `maxBatchSize` run concurrently with no cap, and a
363
+ sequential `for … await loader.load(x)` loop batches nothing by construction
364
+ (each `await` closes the microtask window). Use `loadMany` or `Promise.all`,
365
+ and read `stats.batches` to confirm the round-trip count you expected.
366
+
367
+ ## [0.25.0] - 2026-08-24
368
+
369
+ ### Added
370
+
371
+ - **`CacheAdapterResolver = () => CacheAdapter | undefined`** — a function consulted per call that returns the store for the current scope, or `undefined` for "no cache right now". `undefined` is inert — identical to `enabled: false` — and never falls back to a shared store, because that fallback is the cross-scope leak the scoping exists to prevent.
372
+ - **`CacheAdapterSource = CacheAdapter | CacheAdapterResolver`** — `CacheEngine` and `cachePlugin` now accept either a fixed process-lifetime adapter (unchanged behaviour) or a resolver. A bare adapter is sugar for `() => adapter`; normalised at construction so every call site is on one path.
373
+ - **`CacheAdapterResolver` and `CacheAdapterSource` exported from `@classytic/repo-core/cache`** and re-exported from the plugin index.
374
+
375
+ ## [0.24.0] - 2026-08-17
376
+
377
+ Version bump only — republished 0.23.1's contents under a minor so consumers pinning `>=0.24.0` get the tenant guard by a floor rather than a patch. No source changes over 0.23.1.
378
+
379
+ ## [0.23.1] - 2026-08-17
380
+
381
+ ### Added — `assertNoLegacyTenantKeys(config, pkg, extra?)` (`/tenant`)
382
+
383
+ Refuses a config still carrying a pre-consolidation tenant key (`multiTenant`, plus any package-specific retirees passed via `extra`) instead of ignoring it.
384
+
385
+ The rename onto `TenantConfig` under `tenant` is trivial; the failure mode when a CALLER misses it is not. Every kernel resolver reads `resolveTenantConfig(config.tenant ?? false)`, so an absent `tenant` resolves to `strategy: 'none'` — no tenant field, no tenant filter, every read spanning ALL tenants, no error, and figures that look plausible. A host that asked for tenancy silently gets none.
386
+
387
+ The guard lives beside the resolver every one of those packages already calls, rather than being hand-rolled nine times — nine chances to forget, and the one that forgets ships the leak. Additive and non-breaking: nothing calls it until a package renames.
388
+
389
+ ## [0.23.0] - 2026-08-13
390
+
391
+ ### Added — transactional-core contracts (Phase 1a + 1d) + fencing (Phase 2, first slice)
392
+
393
+ - **Concurrency-conflict taxonomy** (`/errors`): `IsTransientConflictFn` + `neverTransient` default + `conservativeMongoIsTransientConflict` — same ownership rule as `isDuplicateKeyError` (the kit knows its driver; repositories expose `isTransientConflictError`). The default is NO retry: re-running side effects on an unclassified failure is the unsafe direction.
394
+ - **`VersionConflictError`** (`/errors`) + `isVersionConflictError` (survives duplicated copies): the optimistic-CAS violation, 409-shaped, distinct from not-found by contract — a stale version THROWS, never returns `null`, because collapsing the two invites a blind retry that clobbers the concurrent write.
395
+ - **`WriteOptions.ifVersion`** + `RepoCapabilities.optimisticConcurrency`: CAS write contract — apply only at the expected version, increment on success, throw on the option when the capability is absent (a silently dropped guard is the defect, not a degraded mode).
396
+ - **`retryingTransaction(repo, fn, opts)`** (`/repository`): the transactional retry envelope — `withTransaction` re-run on TRANSIENT conflicts only, bounded attempts, full-jitter backoff, abort-aware, classification from the kit predicate. Refuses (throws) a repository without `withTransaction` — no silent degrade. Contains no backoff math of its own: rides `withRetry`.
397
+ - **`withRetry` gains `maxDelayMs` + `jitter` + an abortable sleep** — an aborted caller no longer waits out its backoff (mutation-proven: a plain `setTimeout` there times the pinning test out). Defaults unchanged.
398
+ - **`TransactionHandle`**: `withTransaction`'s callback gains a second argument `{ session? }` — the raw driver handle, so work OUTSIDE the repository (the outbox writer, canonically) can join the same transaction. Kits MUST pass a handle object (possibly empty, for connection-bound backends); a conformance case pins the argument's presence. `retryingTransaction` forwards it structurally.
399
+ - **`LockAdapter.tryAcquireFenced?`** (additive): a successful acquire returns a MONOTONIC token minted by the STORE — a process cannot fence itself. Token increments per ownership CHANGE (extension keeps it; epochs, not heartbeats), so a downstream store can reject a stale ex-holder after lease loss — the overlap serialized renewal narrows but cannot close. `LockState.token`; memory adapter implements it (monotonic for the adapter lifetime — the honest memory limit, documented). Boolean `tryAcquire` unchanged.
400
+ - **`RepoCapabilities.transactionRetry: 'managed' | 'caller'`** — WHO owns retry of a conflict-aborted transaction. `retryingTransaction` used to wrap EVERY kit in its own 5-attempt loop, including MongoDB's convenient transaction API, which already re-runs the callback internally on `TransientTransactionError` / `UnknownTransactionCommitResult` for up to 120s. Two stacked policies: the callback's execution count stopped being bounded by `maxAttempts`, `onRetry` reported a fraction of real attempts, every outer attempt opened a NEW session, and misplaced side effects repeated unpredictably. Retry ownership is now DECLARED — `'managed'` is invoked exactly once, `'caller'` gets the envelope's loop. **Absent means `'managed'`**: a missing retry surfaces a conflict as a 409, a nested one re-runs side effects without bound, so silence must mean "don't" (same posture as `neverTransient`). `retryOwner` overrides for repositories that cannot declare.
401
+ - **`retryingTransaction` is capability-aware** — a repository that publishes a descriptor is held to it (`transactions !== true` → throw, which `'unknown'` reports, failing closed). Method presence was never the test: kits expose `withTransaction` unconditionally and fail at BEGIN.
402
+ - **`asReadOnlyRepo(repo, { reason })` + `RepoCapabilities.readOnly`** (`/repository`) — seal a repository whose rows another writer owns (Better Auth's identity collections, a SQL view, a read replica). Writes throw `ReadOnlyRepositoryError` (synchronously, so a forgotten `await` still crashes); reads pass through. A Proxy rather than a wrapper so a kit method this module has never heard of is sealed BY DEFAULT instead of leaking. `readOnly` lets a host refuse write ROUTES at boot rather than surfacing the wall on the first request.
403
+ - **`TransactionHandle` is exported from `/repository`** — it was defined in `types.ts` but never re-exported, so consumers restated it structurally as `{ session?: unknown }` and would silently miss any field added to the contract.
404
+ - **Conformance: capability-vs-behaviour assertions** — a transactional repo must declare `transactionRetry`, and `nestedTransactions` must match what a nested `withTransaction` call ACTUALLY does. Both were wrong in a shipped kit: mongokit declared `nestedTransactions: true` while its tx-bound proxy threw on the very same call.
405
+ - **Conformance: `ifVersion CAS` block** (gated on `optimisticConcurrency`): matching version applies + increments, stale version throws `VersionConflictError` and the losing write does not apply, not-found stays `null`. New optional harness fields `versionField` / `missingId`.
406
+
407
+ ## [0.22.0] - 2026-08-12
408
+
409
+ ### Added — `pages: 0` conformance tests for empty results
410
+
411
+ - `runStandardRepoConformance` now asserts `pages: 0` for empty offset envelopes (both the primary `getAll` path and the `aggregatePaginate` path). The field had no assertion anywhere in the suite; two kit paths diverged silently: the primary offset path computed `ceil(0/limit) = 0` while aggregate and lookup paths used `Math.max(1, ceil(...)) = 1`. The contract is `0` — zero rows fill zero pages — and it is what every kit's primary path already returned. The missing assertion let the drift reach a consumer as golden-fixture breakage when a `lookups` join rerouted a list read onto the divergent path.
412
+
413
+ ## [0.21.0] - 2026-08-10
414
+
415
+ ### Added — LRU-bounded `createMemoryCacheAdapter`
416
+
417
+ - `createMemoryCacheAdapter(options?)` now accepts `MemoryCacheAdapterOptions` with a `maxEntries` cap (default `10,000`). Past the cap the least-recently-used entry is evicted — read promotion (delete + re-insert) keeps the map ordered by recency so eviction is O(1). A TTL alone does not bound the map: entries only expire when read, so a high-cardinality keyspace (per-tenant, per-commit, per-filter keys) grows monotonically until the process dies. Eviction is always safe for a cache, so the bound is on by default. Set `maxEntries: 0` to restore the previous unbounded behaviour.
418
+ - `MemoryCacheAdapterOptions` exported from `@classytic/repo-core/cache`.
419
+
420
+ ### Added — timezone-aware date buckets (`AggDateBucket.timezone`)
421
+
422
+ - `AggDateBucket.timezone` — IANA zone the bucket boundaries are drawn in; absent means UTC. A UTC day is not a business day in any non-UTC deployment: rows from 18:00 to midnight local fall in the previous UTC day, silently reporting the wrong period in daily/monthly rollups.
423
+ - `AggregateOpsSupport.dateBucketTimezone` capability flag — a kit that cannot draw DST-correct boundaries (e.g. SQLite, no tz database) MUST declare `dateBucketTimezone: false` and throw when the field is set rather than silently bucketing in UTC. Mongokit (`$dateTrunc` / `$dateToString` both accept `timezone`) declares `true`.
424
+
425
+ ## [0.20.0] - 2026-08-04
426
+
427
+ ### Added
428
+
429
+ - **`ResolveBetterAuthCollectionsOptions.exclude`** — canonical collection names
430
+ to omit from `resolveBetterAuthCollections()` output. Applied before
431
+ `modelOverrides` / `usePlural` so a caller names the canonical collection
432
+ (`'user'`) and never has to predict the final model name. Solves the collision
433
+ when a host registers stub models for BA collections it merely references and a
434
+ full `createBetterAuthOverlay` for the one it exposes CRUD on — both for the
435
+ same collection would cause mongoose to lock the schema on first `model()`,
436
+ silently dropping `additionalFields` and causing the overlay to refuse.
437
+
438
+ ## [0.19.0] - 2026-07-29
439
+
440
+ ### Added — `coerceFilterDates` + ISO date helpers (`./filter`)
441
+
442
+ - **`coerceFilterDates(filter)`** — walks a record-shape filter (Mongo-dialect or bare bracket-op syntax) and coerces ISO-8601 strings on range operators (`gt`/`gte`/`lt`/`lte` and `$`-prefixed equivalents) to `Date`, recursing through `$and`/`$or`/`$nor`/`$not` logical wrappers. Returns a new object; the input is never mutated. Equality operators are deliberately excluded — a string that happens to look like a date is far more likely a string id than a date equality predicate. Fixes silent empty results on aggregation `$match` stages: MongoDB `$match` (unlike `find`) performs no schema casting, so a string compared to a Date column matches nothing.
443
+ - **`tryCoerceIsoDate(value)`** — coerces one unknown value to a `Date` when it is an unambiguous ISO-8601 string; returns anything else untouched. Safe to apply unconditionally.
444
+ - **`ISO_DATE_PATTERN`** — the single regex source of truth for tight ISO-8601 detection (date-only through millisecond precision + optional timezone). `query-parser/coerce.ts` now imports this instead of keeping a duplicate pattern — one definition means the URL boundary and the compile boundary can never disagree on what "looks like a date".
445
+
446
+ All three are exported from `@classytic/repo-core/filter`. Purely additive.
447
+
448
+ ## [0.18.0] - 2026-07-27
449
+
450
+ ### Added — `definePurgeStep` builder (`./cleanup`) and `resolveTenantField` (`./tenant`)
451
+
452
+ #### `./cleanup` — `definePurgeStep`
453
+
454
+ - **`definePurgeStep(repository, spec)`** — standard builder for the most common
455
+ `CleanupStep` shape: a chunked `purgeByField` over a single scope value.
456
+ Nearly every provider step in a domain kernel is this pattern; the builder owns
457
+ the invariant core so callers only declare what differs:
458
+ - **fail-closed scoping** — missing scope value or unavailable repository is a
459
+ `BLOCKER` (not a silent no-op and never an unscoped purge that would hit every
460
+ tenant); blocked by `SCOPE_REQUIRED:<param>` / `REPOSITORY_UNAVAILABLE:<id>`.
461
+ - **cancellation** — `throwIfCancelled` is called before work starts and the
462
+ `signal` is threaded to the kit so a cancel lands between committed chunks.
463
+ - **honest failure** — a failing purge returns `ok: false` so the recipe composer
464
+ stops (retention §8); thrown errors are caught and reported, never swallowed.
465
+ - **verification** — absence is re-queried after the run via `countDocuments`
466
+ (`spec.verifyFilter` for steps whose match filter differs from the absence proof).
467
+ - **`PurgeStepSpec`** — declaration object: `id`, `resource`, `parameter`, `field`,
468
+ `strategy`, optional `retained`, `warnings`, `batchSize`, `guard(scope, ctx)`,
469
+ `verifyFilter(scope)`, `verifyName`.
470
+ - **`PurgeStepRepository`** — structural subset of `StandardRepo` satisfied by any
471
+ kit repository that implements `purgeByField` + `countDocuments`.
472
+ - **`SCOPE_REQUIRED`** / **`REPOSITORY_UNAVAILABLE`** — blocker-code prefix constants.
473
+
474
+ #### `./tenant` — `resolveTenantField`
475
+
476
+ - **`resolveTenantField(config?)`** — returns the single `tenantField` string a
477
+ resource layer needs for `defineResource({ tenantField })`, or `false` when the
478
+ config disables tenant scoping (`false` / `{ enabled: false }` / `{ strategy: 'none' }`).
479
+ Four spine modules hand-rolled equivalent logic independently, each re-deriving
480
+ `'organizationId'` as the default; one copy omitted the disable branch, making
481
+ that package silently un-configurable. This function is the single definition.
482
+
483
+ Both additions are purely additive — no breaking changes.
484
+
485
+ ## [0.17.0] - 2026-07-25
486
+
487
+ ### Added — `./cleanup`: framework-free cleanup provider step contract
488
+
489
+ - **`@classytic/repo-core/cleanup`** — new subpath (pure types, no runtime).
490
+ Domain kernels (`@classytic/flow`, `@classytic/order`, `@classytic/facts`,
491
+ `@classytic/ledger`, …) export `CleanupStep[]` for the data they own; a host
492
+ framework (`@classytic/arc/cleanup`) folds them into a Cleanup Center recipe.
493
+ The contract lives in repo-core so kernels stay framework-free: kernels already
494
+ depend on repo-core (the chunked-purge mechanics) and MUST NOT depend on arc.
495
+ - **`CleanupStep`** — the core provider interface: `id`, `resource`, `destructive`,
496
+ optional `rebuildActions`; and three lifecycle methods: `estimate(ctx)` (preview
497
+ without mutating), `execute(ctx)` (chunked, idempotent, cancellation-aware),
498
+ and optional `verify(ctx)` (post-checks — a delete count alone is never success).
499
+ - **Supporting types**: `CleanupStepContext` (injected `now`, `signal`, `ambient`,
500
+ `parameters`, `logger`), `CleanupStepExecuteContext` (adds `onProgress` +
501
+ `throwIfCancelled`), `CleanupStepEstimate` (row count, `retained`, `blockers`,
502
+ `warnings`), `CleanupStepOutcome` (`processed`, `ok`, `error`, `cursor`),
503
+ `CleanupStepCheck` (`name`, `ok`, `detail`), `CleanupStepProgress`, `CleanupStepLogger`.
504
+
505
+ Purely additive — zero runtime code, type-only subpath export.
506
+
507
+ ## [0.16.0] - 2026-07-24
508
+
509
+ ### Added — `runPurgeConformance`: cross-kit chunked-purge contract suite
510
+
511
+ - **`runPurgeConformance(harness)`** (from `@classytic/repo-core/testing`) —
512
+ proves a kit's purge port makes **stable progress for every strategy** when
513
+ the match set exceeds `batchSize`. Every scenario seeds more rows than one
514
+ batch; a chunk budget converts a non-progressing port's infinite loop into a
515
+ crisp assertion failure. Scenarios: hard drain, soft multi-batch WITHOUT
516
+ caller-supplied exclusion predicates, anonymize static + function-form,
517
+ exact-batch boundary, skip, empty scope, abort-between-chunks.
518
+ - **`PurgePort` progression contract (documented, mandatory)** — successive
519
+ `purgeChunk` calls MUST advance through the match set for every strategy via
520
+ stable keyset progression (`pk > lastSeen`, advanced only after the chunk's
521
+ write succeeds). Re-running the bare predicate only self-advances for
522
+ `hard`; `soft`/`anonymize` re-select the same first chunk forever. Offsets
523
+ are explicitly ruled out. A port instance is single-run state.
524
+
525
+ ### Fixed
526
+
527
+ - `purgeByField` docstring no longer claims soft/anonymized rows "simply don't
528
+ match the next pass" — they generally DO still match; idempotency holds by
529
+ outcome convergence, and within-run progression is the port's keyset
530
+ responsibility.
531
+
532
+ ## [0.15.0] - 2026-07-24
533
+
534
+ ### Added — `purgeByFilter`: range/filter-scoped purge + anonymize
535
+
536
+ - **`StandardRepo.purgeByFilter?(filter, strategy, options)`** — the
537
+ range/filter-scoped sibling of `purgeByField`. Where `purgeByField` matches
538
+ a single `field = value` equality, this optional method takes the full
539
+ portable `FilterInput` (Filter IR or a plain kit-native record) and runs any
540
+ `TenantPurgeStrategy` (`hard` / `soft` / `anonymize` / `skip`) over the
541
+ matched slice. THE compliance primitive for "purge/anonymize a dimension
542
+ across a RANGE while RETAINING measures" — redact a PII column across a
543
+ `civilDate` window, hard-delete rows past a retention cutoff, soft-delete a
544
+ compound cohort. Returns the same `TenantPurgeResult` envelope; chunking,
545
+ index requirement, idempotency, plugin composition, and narrowed-write
546
+ re-assertion are identical to `purgeByField`. Gate on the new
547
+ `capabilities.purgeByFilter`.
548
+ - **`RepoCapabilities.purgeByFilter?: boolean`** — feature-detection flag,
549
+ mirroring `purgeByField`.
550
+ - **`PurgePort` doc** clarifies the two bound-predicate forms (equality-bound
551
+ vs filter-bound) both satisfy the single port interface, so `runChunkedPurge`
552
+ drives both unchanged.
553
+
554
+ Strictly additive: `purgeByField`, `TenantPurgeStrategy`, and `runChunkedPurge`
555
+ are unchanged; both new members are optional.
556
+
557
+ ## [0.14.0] - 2026-07-16
558
+
559
+ ### Added — canonical `matchesRecordFilter` (the `DataAdapter.matchesFilter` home)
560
+
561
+ - **`matchesRecordFilter(item, record)`** + **`policyRecordToFilter(record)`**
562
+ (`@classytic/repo-core/filter`) — THE single, shared implementation of the
563
+ `DataAdapter.matchesFilter` seam. Evaluates arc's Mongo-record `_policyFilters`
564
+ (`{ ownerId }`, `{ organizationId }`, `{ $or: [{ ownerId }, { _id: { $in } }] }`)
565
+ against an already-fetched document IN PROCESS, by converting to the portable
566
+ `Filter` IR and delegating to `matchFilter` — the SAME IR kits compile to
567
+ SQL/Mongo, so in-memory and DB enforcement agree by construction. Every kit's
568
+ adapter now delegates here; **no per-kit matcher, one contract, one IR.**
569
+ Operator scope: `$or`/`$and`/`$nor`/`$not`, `$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`,
570
+ `$in`/`$nin`, `$exists`, implicit-eq; fails LOUD on anything else. (Distinct from
571
+ `recordToFilter`, the bare-operator query normalizer that does not accept
572
+ `$`-prefixed or logical operators.)
573
+ - **`matchFilter` is now fully array + id + date aware** (additive superset;
574
+ primitive/Date-vs-Date behavior unchanged — strict cases still short-circuit):
575
+ - **id coercion** — an id-like object with a meaningful `toString` (Mongo
576
+ `ObjectId`, `Buffer`, `Decimal128`) matches its string form, so one shared
577
+ matcher serves Mongo (`ObjectId` `_id`) and SQL (primitive ids) alike.
578
+ - **array semantics** — dot-paths fan out over subdocument arrays
579
+ (`items.sku` on `[{sku},{sku}]`), and a scalar condition on a leaf array
580
+ field matches when ANY element satisfies it (`{ tags: 'x' }`,
581
+ `{ scores: { $gt: 5 } }`, regex on array elements). All array unwrapping is
582
+ concentrated in one helper; the comparators stay pure scalar.
583
+ - **Date⇄ISO-string range** — `compare` coerces the string side to a date
584
+ instant when the other side is a genuine `Date` (mirrors `equals`), and
585
+ never claims ordering across a number/string type boundary (fails closed
586
+ instead of matching spuriously).
587
+ - **prototype-pollution-safe reads** — path resolution uses `Object.hasOwn`,
588
+ so a crafted `{ '__proto__.x': … }` / `{ constructor.name: … }` filter can
589
+ never traverse the prototype chain.
590
+ - **Ability parity across kits**: mongokit's earlier standalone matcher
591
+ (subdocument-array fan-out, array-contains, `$regex`, ObjectId coercion) is now
592
+ fully covered by the shared engine — nothing was lost in consolidation.
593
+ - **MongoDB-parity hardening** (validated against the MongoDB manual + sift.js +
594
+ mingo — the two industry-standard in-memory matchers). The authorization-critical
595
+ rule "an absent field participates in comparisons as null/undefined" is now
596
+ fully honored:
597
+ - `{ field: null }`, `$ne`, `$nin`, and a `null` MEMBER of `$in`/`$nin` all
598
+ match a MISSING field (a policy filter `{ status: { $ne: 'archived' } }`
599
+ correctly returns docs that lack the field, exactly as MongoDB does — the top
600
+ silent-authorization-divergence trap). `$ne: null` remains the exception
601
+ (requires present + non-null).
602
+ - `$in` accepts RegExp-literal members (`{ name: { $in: [/^a/] } }`).
603
+ - Comparison ops are type-bracketed (no cross-type ordering; `$gt: null` matches
604
+ nothing); NaN equals NaN for `$eq` (via `Object.is`, not `===`).
605
+ - Numeric dot-path segments resolve as positional array indices (`items.0.sku`).
606
+ - Documented DELIBERATE divergences: `$exists` = present-and-non-null (matches
607
+ the IR `exists` op + SQL `IS NOT NULL` + sift; Mongo/mingo count present-null
608
+ as existing); `Date`⇄ISO-string range leniency; array-literal operands are
609
+ element-matched not exact-matched.
610
+ - New: 41-case matcher suite incl. the 11 researched MongoDB gotchas + adversarial
611
+ (prototype pollution, NaN/Infinity, boolean/zero/empty-string, empty
612
+ `$in`/`$or`/`$and`/`$nor`, fail-loud on unsupported operators) + a 100k-doc
613
+ performance smoke (linear, cached regex).
614
+ - **Security hardening** (from a review against sift.js/mingo CVEs + the OWASP
615
+ NoSQL-injection / ReDoS / prototype-pollution literature):
616
+ - **Prototype-key denylist** on path segments — `__proto__` / `constructor` /
617
+ `prototype` resolve to nothing (fail-closed), string-normalized (the
618
+ object-path CVE-2021-23434 lesson: an array-typed segment bypassed a `===`
619
+ check). Closes the "match an inherited member → wrong auth answer" case and
620
+ the `JSON.parse('{"__proto__":…}')` own-property vector, on top of the
621
+ already-`Object.hasOwn` reads.
622
+ - **`$regex` input-length cap** (64 KiB) — ReDoS is `pattern × input`; a field
623
+ value longer than the cap is treated as no-match (fail-closed) so one slow
624
+ match can't stall the event loop and amplify across a realtime fan-out
625
+ (matcher runs once per subscriber per record). Pattern-side ReDoS is a
626
+ non-issue for framework-supplied (trusted) patterns.
627
+ - Reaffirmed **fail-closed** posture: comparisons never coerce across a
628
+ number/string type boundary (return no-match rather than JS-coerced nonsense
629
+ — the classic over-visibility leak), and unsupported operators throw.
630
+
631
+ ## [0.13.0] - 2026-07-15
632
+
633
+ ### Added — `StandardRepo.applyTransition?()` contract (state-machine CAS with history)
634
+
635
+ - **`TransitionMachine`** — the canonical structural contract for the state
636
+ machine (`{ name, assertTransition }`). Primitives'
637
+ `defineStateMachine()` output and mongokit's local `TransitionMachine`
638
+ are structurally identical by design; nobody imports anybody (same
639
+ policy as `ClaimTransition`).
640
+ - **`TransitionArgs`** — `{ from, to, field?, set?, push?, where?, by?,
641
+ note?, history?, at? }`.
642
+ - **`StandardRepo.applyTransition?(id, machine, args, options)`** — OPTIONAL
643
+ member declaring the state-machine-backed CAS transition mongokit 3.22
644
+ ships as a class primitive: legality pre-flight via the machine (which
645
+ throws the domain's typed error), CAS via `claim`, `$push`ed status
646
+ history, and accurate race-loss diagnosis (machine error from the row's
647
+ CURRENT state / 404 `TRANSITION_TARGET_MISSING` / 409
648
+ `TRANSITION_RACE_LOST`). Unlike `claim`/`claimVersion` it THROWS rather
649
+ than returning `null` — it owns the error liturgy domain packages used
650
+ to copy-paste. Promote to required once sqlitekit implements it.
651
+
652
+ ### Added — `./usage` subpath (period-bucketed counter contract)
653
+
654
+ - **`UsageStore`** — driver-agnostic interface for atomic period-bucketed counters: `increment(bucket, amount)` + `summary(actor, period)`. The storage seam under platform accounting (quotas, plan enforcement, usage-based billing). One cell = `(actor, period, kind)`; one write = atomic upsert; one read = all counters for an actor-period pair. Kits ship adapters (`@classytic/mongokit/usage`, `@classytic/sqlitekit/usage`, …) without depending on arc; `@classytic/arc/usage` consumes this contract structurally.
655
+ - **`UsageBucket`** — `{ actor, period, kind }` tuple. `kind` is dot-namespaced: `api.requests`, `ai.tokens.input`, `storage.egress.bytes`.
656
+ - **`usagePeriod(date?)`** — canonical UTC calendar-month key (`"2026-07"`). Monthly is the billing-native granularity.
657
+ - **`createMemoryUsageStore()`** — in-process reference implementation for tests and single-instance apps. Returns `UsageStore & { clear() }`. Multi-replica deployments need a shared adapter.
658
+
659
+ ### Added — `runUsageStoreContract` in `./testing`
660
+
661
+ - **`runUsageStoreContract(harness)`** — cross-kit conformance suite. Kits import once and pass their adapter; all canonical scenarios run automatically. Same pattern as `runLockAdapterConformance`. `UsageConformanceHarness` is exported from `./testing` for harness typing.
662
+
663
+ ## [0.10.0] - 2026-07-13
664
+
665
+ ### Added — `ValidationErrorMeta.path` + `.meta` (field-scoped validation errors)
666
+
667
+ - **`ValidationErrorMeta.path?: string`** — dot-path to the offending field
668
+ (e.g. `'journalItems.2.account'`). Set by kits with field-scoped validation
669
+ (ledger's `FieldError`, Mongoose `ValidationError`). Absent when the kit doesn't
670
+ have a field path.
671
+ - **`ValidationErrorMeta.meta?: Readonly<Record<string, unknown>>`** — non-PII
672
+ structured extra for the field (e.g. `{ value: 'bad' }`). Never include secrets.
673
+ - **`toErrorContract`** now forwards `path` and `meta` from each `validationErrors`
674
+ entry onto the wire `ErrorDetail`. Previously only `code` and `message` were mapped;
675
+ field paths were silently dropped. No breaking change — both fields are optional and
676
+ additive. Kits that don't set them produce the same wire shape as before.
677
+
678
+ ## [0.9.0] - 2026-07-11
679
+
680
+ ### Added — `StandardRepo.getByIds` (batch point-read)
681
+
682
+ - **`getByIds?(ids: readonly string[], options?: FindAllOptions): Promise<Map<string, TDoc>>`**
683
+ added to `StandardRepo` — the `$in` / `IN (...)` counterpart to `getById`. The N+1
684
+ killer for callers resolving many ids in one tick (order-line snapshotting, dashboard
685
+ row enrichment, reference expansion).
686
+
687
+ **Contract:**
688
+ - Kit de-duplicates ids before the driver round-trip.
689
+ - Ids with no matching doc are ABSENT from the returned `Map` — never throw on a miss.
690
+ - Structurally invalid ids are treated as misses (one malformed id must never fail the
691
+ batch).
692
+ - Tenant scoping, soft-delete, and hooks apply exactly as they would to `getById`.
693
+ - Empty input → empty Map, no round-trip.
694
+ - Optional: kits adopt incrementally. Callers feature-detect
695
+ (`repo.getByIds ?? per-id fallback`) until their kit floor ships it.
696
+ - Sizing is backend-bound (Mongo: 16 MB query document); keep batches ≤ ~10k ids and
697
+ chunk beyond.
698
+
699
+ - **`events.ts` clarification** — `getByIds` has no `BEFORE_GET_BY_IDS` event by design.
700
+ It routes through `findAll`, so observers see the batch as `before:findAll` / `after:findAll`
701
+ with an `$in` filter. The comment guards against accidentally adding a separate event.
702
+
703
+ - **New type-level contract test** (`tests/unit/repository/get-by-ids-contract.test.ts`) —
704
+ pins the `getByIds` shape for kits implementing it: `Map<string, TDoc>` keyed by
705
+ stringified id, `readonly string[]` input. Verifies that kits without `getByIds` still
706
+ conform (the member is optional).
707
+
708
+ ### Changed
709
+
710
+ - `ChangesPage.changes` type: `ReadonlyArray<ChangeEntry<TDoc>>` → `readonly ChangeEntry<TDoc>[]`
711
+ (canonical form — functionally identical).
712
+ - devDeps: `tsdown ^0.21.8 → ^0.22.5`, `typescript ^6.0.3 → ^7.0.2`.
713
+
714
+ ## [0.8.1] - 2026-07-11
715
+
716
+ ### Docs — `DataAdapter.close()` ownership rule
717
+
718
+ - Clarified the `close?()` contract (doc-only, no type/runtime change): the adapter does **not** own the database connection — the host does, and `close()` must release only adapter/kit-owned resources (TTL/vacuum timers, change streams), never disconnect a shared client/pool. Kits that genuinely own a connection gate its disposal behind an explicit opt-in. This standardizes cleanup semantics across mongokit/sqlitekit/pgkit/prismakit.
719
+
720
+ ## [0.8.0] - 2026-07-08
721
+
722
+ ### Added — data-lifecycle contract (archive, streaming, distribution awareness)
723
+
724
+ Additive release: existing kits on 0.7 semantics compile and pass conformance unchanged (new scenarios are capability-gated and default to skipped).
725
+
726
+ - **`archiveByFilter` + `runChunkedArchive`** (`repository/archive.ts`) — the cold-storage twin of `purgeByField`. Hosts provide an `ArchiveSink` (archive table/collection, JSONL, warehouse loader); kits provide an `ArchivePort` (`readChunk` in stable PK order / `deleteChunk` by docs). The orchestrator enforces **write-before-delete** (a crash re-archives, never loses; sinks must be duplicate-tolerant — at-least-once by design), chunked `batchSize` (default 1000), per-step retry via the shared `RetryPolicy`, abort between chunks, cumulative progress, and phase-tagged errors (`read` / `sink` / `delete` — a `sink` failure guarantees rows are still hot). New capability flag: `archiveByFilter`.
727
+ - **`cursor()` declared on `StandardRepo`** (+ `CursorOptions`) — the streaming-reads method mongokit and sqlitekit already implement is now part of the portable contract (`AsyncIterable<TDoc>`, internal `batchSize` fetching, non-snapshot semantics documented). Gated by the existing `streaming` capability.
728
+ - **Distribution-key awareness** (`repository/distribution.ts`) — `DistributionConfig` (`key`, `onMissingKey: 'warn' | 'throw' | 'off'`, `exemptOperations`) + `createDistributionGuard` + `filterReferencesKey` (Filter IR via `collectFields`, raw records incl. `$and`/`$or`/`AND`/`OR` branches). Catches shard/partition-key-missing scatter-gather queries at the access layer; deliberately does NOT implement routing/rebalancing/partition DDL — those stay database-native (Mongo sharding, pg_partman/Timescale).
729
+ - **Conformance**: new capability-gated scenario groups — `archiveByFilter` (sink round-trip, write-before-delete on sink failure, chunking + progress, idempotent re-run, abort partial) and `cursor` (exact-once iteration across batch boundaries, early-break safety).
730
+
731
+ ## [0.7.0] - 2026-07-04
732
+
733
+ ### Added — `./sync` change-log / cursor contract
734
+
735
+ Storage-agnostic data-sync spine for offline-first + incremental replication:
736
+ `ChangeLogStore`, `ChangeEntry` (tombstone deletes), pull `ChangesPage`
737
+ (opaque exclusive cursors, `hasMore` paging), Replicache-style `PushMutation`
738
+ / `PushVerdict` (idempotent client mutation ids, server-authoritative
739
+ conflicts), `CursorExpiredError` (compaction horizon → full resync), and the
740
+ `MemoryChangeLogStore` reference impl pinning the contract semantics.
741
+
742
+ Synthesized from CouchDB `_changes` (tombstones/checkpoints), Drive/Graph
743
+ delta APIs (opaque server-issued cursors), Mongo change-stream resume tokens,
744
+ and Replicache push/pull. Contract only — kits (mongokit/prismakit/sqlitekit)
745
+ implement capture + durable stores via repository plugins; arc re-exports the
746
+ surface at `@classytic/arc/sync`; sync HTTP endpoints are an arc module.
747
+
748
+ ## [0.6.1] - 2026-07-04
749
+
750
+ ### Changed — `TenantConfig` optionals widened to `T | undefined` (P10)
751
+
752
+ Every optional prop on `TenantConfig` (`strategy`, `enabled`, `tenantField`,
753
+ `fieldType`, `ref`, `contextKey`, `required`, `resolve`) is now typed
754
+ `T | undefined`, so downstream packages compiling with
755
+ `exactOptionalPropertyTypes: true` can extend it without redeclaring props
756
+ (unblocks `@classytic/ledger`'s `MultiTenantConfig.required` exception).
757
+
758
+ `resolveTenantConfig` strips explicit-`undefined` keys before spreading
759
+ over `DEFAULT_TENANT_CONFIG`, so `{ required: maybeUndefined }` can never
760
+ clobber a default with `undefined` — previously the un-widened type made
761
+ that unrepresentable; now it's handled at runtime and pinned by tests.
762
+ `DEFAULT_TENANT_CONFIG`'s annotation moved from `Required<Pick<...>>` to an
763
+ `Exclude<..., undefined>` mapping (`-?` does not strip an explicit
764
+ undefined union member). No runtime behavior change for existing callers.
765
+
766
+ ## [0.6.0] - 2026-06-11
767
+
768
+ Standardization release. Coordinated with mongokit 3.16 + sqlitekit 0.6.
769
+
770
+ ### Added
771
+
772
+ - **`StandardRepo.capabilities: RepoCapabilities`** (required) — runtime feature detection (`arrayOperators`, `changeStreams`, `regexFilter`, `lean`, `streaming`, `lookupPopulate`, + conformance flags). `ConformanceFeatures` is now an alias of the same type — runtime declaration and conformance gating cannot drift.
773
+ - **Standard Schema validation** — `RepositoryBaseOptions.schema` / `updateSchema` accept any Zod / Valibot / ArkType / Effect schema; runs at new `HOOK_PRIORITY.VALIDATION` (150). Failures throw `HttpError` 400 with `validationErrors`. Vendored `StandardSchemaV1` types + `validateStandardSchema()` on `/schema`.
774
+ - **Domain events** (`/events` subpath) — `RepositoryBaseOptions.events: { transport }` publishes `<resource>.<verb>` events through any arc/primitives-compatible transport. Publish failures never fail the op (routed to `error:events`).
775
+ - **Change feed contract** — optional `StandardRepo.watch?()` returning `AsyncIterable<ChangeEvent<TDoc>>`; gated by `capabilities.changeStreams`.
776
+ - **Resilience** — `QueryOptions.signal` + `QueryOptions.retryPolicy`; shared `withRetry()` / `throwIfAborted()` helpers.
777
+ - **`recordToFilter()`** on `/filter` — promoted from per-kit copies.
778
+ - `STANDARD_REPO_OPTION_KEYS` gains `'traceId'`.
779
+
780
+ ### Changed
781
+
782
+ - `PurgeRetryPolicy` → **`RetryPolicy`** (rename, identical shape; was declared twice — now once). Clean break, no alias.
783
+ - `AggregateOpsSupport` moved to `repository/capabilities.ts` (still re-exported from `/testing`).
784
+
785
+ ## [0.5.0] - 2026-05-17
786
+
787
+ ### Added — compliance-grade tenant cleanup primitive
788
+
789
+ Cross-kit foundation for "what happens to this data on org-delete?" —
790
+ GDPR right-to-be-forgotten, SOC 2 deletion timelines, HIPAA / PCI
791
+ retention rules. Every kit (mongokit, sqlitekit, future pgkit) gets
792
+ the same surface; arc's `cascadeDeleteForOrganization` runner composes
793
+ on top.
794
+
795
+ - **`StandardRepo.purgeByField?(field, value, strategy, options)`** — new optional method. Processes every row matching `field = value` under a declared strategy, chunked under the hood. Optional because not every store needs the surface; arc's cascade runner checks for the method at boot.
796
+ - **`TenantPurgeStrategy`** discriminated union — four variants:
797
+ - `{ type: 'hard' }` — permanent removal (GDPR right-to-be-forgotten).
798
+ - `{ type: 'soft', deletedField?, deletedAtField? }` — recoverable; pairs with TTL indexes for eventual hard-purge.
799
+ - `{ type: 'anonymize', fields }` — retain rows but overwrite declared fields (HIPAA / PCI / SOX-compatible).
800
+ - `{ type: 'skip', reason }` — explicit opt-out with **mandatory** `reason` (compliance forcing function — silent skips are leaks).
801
+ - **`TenantPurgeOptions`** — `batchSize`, `session`, `onProgress`, `signal`. Chunking is mandatory (10M-row tenants can't run as a single `deleteMany`); abort signal is checked between chunks (never mid-write); aborted runs return `ok: false` with cumulative `processed` count (at-least-once cleanup semantics).
802
+ - **`TenantPurgeResult`** + **`TenantPurgeProgress`** — typed result envelope + per-chunk progress event.
803
+
804
+ ### Added — kit-agnostic orchestrator (`runChunkedPurge`)
805
+
806
+ The chunk-loop logic — abort handling, progress emission, error-wrapping into result envelope, natural-exit on non-full batch — is identical across kits. Extracting it here means a single bug fix lands for every kit, and the surface a new kit has to implement shrinks to ~80 lines.
807
+
808
+ - **`runChunkedPurge(strategy, options, port)`** — pure orchestrator (130 lines, no I/O).
809
+ - **`PurgePort`** interface — the driving port. Each kit implements a single closure: `purgeChunk(strategy, limit)` — one method lets each driver pick its own round-trip shape (sqlite hard-strategy compiles to one `DELETE … LIMIT`; a two-method `selectChunkIds` + `applyStrategy` split would force 2 round-trips per chunk for every kit).
810
+ - **`WritingPurgeStrategy`** — strategy union with `skip` excluded (orchestrator handles `skip` before the port is consulted, so ports only see `hard` / `soft` / `anonymize`).
811
+
812
+ Hexagonal pattern: orchestrator is the use-case, `PurgePort` is the driving port, each kit's port factory is the adapter. Adding a new strategy (e.g. `archive`) = one union member + one case per port. Adding a new kit = one port file + ~10-line method.
813
+
814
+ ### Added — 8 cross-kit conformance scenarios
815
+
816
+ In `src/testing/conformance.ts`, gated by the new `ConformanceFeatures.purgeByField?: boolean` flag. When both mongokit and sqlitekit pass the same scenarios, cross-kit byte-stability for tenant cleanup is provable:
817
+
818
+ 1. `hard` removes every matching row, leaves others intact
819
+ 2. `hard` empty match → `processed: 0`, `ok: true`
820
+ 3. `anonymize` overwrites declared fields, keeps the row
821
+ 4. `skip` is a no-op, returns reason
822
+ 5. Chunking: `batchSize` honored, `onProgress` fires per chunk
823
+ 6. Idempotent: re-running on the same tenant is a no-op
824
+ 7. Scoping: only matching rows affected (cross-tenant safety)
825
+ 8. Abort signal: stops between chunks, returns partial count + `ok: false`
826
+
827
+ The `soft` strategy is intentionally NOT in the conformance suite — it requires writable `deleted` / `deletedAt` fields not present on the shared `ConformanceDoc`; each kit covers `soft` in its own integration tests.
828
+
829
+ ### Migration notes
830
+
831
+ - **Existing kits:** the new method is optional — kits don't break. mongokit 3.14.0 and sqlitekit 0.4.0 ship implementations; older kit versions continue to work, they just can't honor a `purgeByField` call.
832
+ - **Existing hosts:** no breaking changes. Hosts using arc's `cascadeDeleteForOrganization` automatically pick up the new strategy surface once arc 2.16.0 lands.
833
+ - **Build sync (workspace dev):** kits need `cp -r dist/* ../mongokit/node_modules/@classytic/repo-core/dist/` etc. after a workspace bump until npm publish.
834
+
835
+ ## [0.4.0] - 2026-05-04
836
+
837
+ ### Added — kit-shared building blocks (consolidation)
838
+
839
+ - **`@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.
840
+ - **`@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.
841
+
842
+ ### ⚠️ BREAKING — `cache/deferred.ts` removed (use `Promise.withResolvers()`)
843
+
844
+ - **`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.
845
+ - **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.
846
+
847
+ ### Fixed — security & robustness hardening
848
+
849
+ - **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`)
850
+ - **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`)
851
+ - **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`)
852
+
853
+ ### Cache hash upgrade (djb2 → FNV-1a 64-bit)
854
+
855
+ - **`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.
856
+ - **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.
857
+
858
+ ### Cache layer — atomic counters, parallel invalidation, prefetch, timeout
859
+
860
+ - **`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).
861
+ - **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.
862
+ - **`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.
863
+ - **`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.
864
+ - **`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.
865
+
866
+ ### Cache layer — production hardening (TanStack-aligned) + cross-runtime
867
+
868
+ Six gaps in the v1 unified cache layer fixed before any prod traffic:
869
+
870
+ 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.
871
+ 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.
872
+ 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.)
873
+ 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.
874
+ 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.
875
+ 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 })`.
876
+
877
+ ### Code organization
878
+
879
+ The unified plugin (`@classytic/repo-core/cache`) is now split into focused modules:
880
+
881
+ ```
882
+ cache/
883
+ plugin/
884
+ index.ts # cachePlugin factory + types + handle (~200 LOC)
885
+ context.ts # typed context slots + extraction + shape-keys (~170 LOC)
886
+ read-hooks.ts # before/after/error for read ops (~180 LOC)
887
+ invalidation-hooks.ts # after for write ops (~60 LOC)
888
+ swr.ts # background-refresh scheduler (~50 LOC)
889
+ engine.ts # TTL + SWR + tag + version + single-flight (~220 LOC)
890
+ runtime.ts # cross-runtime scheduleBackground (~50 LOC)
891
+ ...
892
+ ```
893
+
894
+ Each module has one purpose. Replaces the prior 564-LOC `plugin.ts` mega-file.
895
+
896
+ ### Added — `Deferred<T>` utility
897
+
898
+ `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.
899
+
900
+ ### Added — Unified cache layer (`@classytic/repo-core/cache`)
901
+
902
+ 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.
903
+
904
+ #### Public surface (`@classytic/repo-core/cache`)
905
+
906
+ ```ts
907
+ import {
908
+ cachePlugin, // hook integration — plugs into RepositoryBase
909
+ CacheEngine, // direct SWR + TTL + tag flow over a CacheAdapter
910
+ buildEnvelope, inspectEnvelope, type CacheEnvelope,
911
+ buildCacheKey, extractScopeTags, type BuildKeyInput,
912
+ appendKeyToTags, invalidateByTags as invalidateByTagsImpl,
913
+ bumpModelVersion, getModelVersion,
914
+ resolveCacheOptions, type CacheOptions, type ResolvedCacheOptions, type CacheReadResult,
915
+ // already shipped:
916
+ type CacheAdapter, createMemoryCacheAdapter, stableStringify,
917
+ } from '@classytic/repo-core/cache';
918
+ ```
919
+
920
+ #### TanStack Query-shaped per-call options
921
+
922
+ Same shape across CRUD + aggregate, kit-agnostic:
923
+
924
+ ```ts
925
+ {
926
+ staleTime?: number; // seconds fresh
927
+ gcTime?: number; // seconds retained past stale (default 60)
928
+ swr?: boolean; // serve-stale + bg refresh
929
+ tags?: readonly string[];
930
+ bypass?: boolean;
931
+ enabled?: boolean;
932
+ key?: string; // explicit override
933
+ }
934
+ ```
935
+
936
+ #### What the plugin does
937
+
938
+ 1. Subscribes to `before:<op>` / `after:<op>` for every read op (`getById`, `getAll`, `getOne`, `getByQuery`, `count`, `exists`, `distinct`, `aggregate`, `aggregatePaginate`) — configurable via `enabled: [...]`.
939
+ 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).
940
+ 3. Auto-injects scope tags (`org:<id>`, `user:<id>`) from `context.filter` so cross-tenant cache poisoning is structurally impossible.
941
+ 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.
942
+ 5. Attaches `repo.cache` handle exposing `invalidateByTags(tags)`, `bumpModelVersion(model)`, `clear()`.
943
+
944
+ #### Strictly-monotonic version bumps
945
+
946
+ `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.
947
+
948
+ #### `AggCacheOptions` is now an alias for `CacheOptions`
949
+
950
+ 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.
951
+
952
+ #### Removed
953
+
954
+ - `/aggregate-cache` subpath — superseded by the unified `/cache` plugin (which handles aggregate ops natively via the `before:aggregate` hook).
955
+
956
+ #### Migration (kit + host)
957
+
958
+ ```ts
959
+ // Before — kit-specific cache plugins + constructor option
960
+ new Repository(model, [cachePlugin({ adapter, ttlSeconds: 60 })], {}, {
961
+ aggregateCache: adapter, // separate constructor option
962
+ });
963
+ repo.aggregate({ measures, cache: { ttl: 60, staleWhileRevalidate: true } });
964
+
965
+ // After — one plugin, one shape
966
+ new Repository(model, [
967
+ multiTenantPlugin({ tenantField: 'orgId' }),
968
+ cachePlugin({ adapter, defaults: { staleTime: 60, gcTime: 300, swr: true } }),
969
+ ]);
970
+ repo.aggregate({ measures, cache: { staleTime: 60, swr: true, tags: ['orders'] } });
971
+ repo.getAll(filter, { cache: { staleTime: 30 } });
972
+ await repo.cache?.invalidateByTags(['orders']);
973
+ ```
974
+
975
+ ### Added — `StandardRepo.claim()` and `claimVersion()` (atomic CAS, REQUIRED on the contract)
976
+
977
+ Standardizes the canonical state-machine write that every domain package was hand-rolling on top of `findOneAndUpdate`:
978
+
979
+ ```ts
980
+ const claimed = await repo.claim?.(runId, { from: 'waiting', to: 'running' }, {
981
+ lastHeartbeat: new Date(),
982
+ workerId: 'worker-12',
983
+ });
984
+ if (!claimed) return; // someone else got it
985
+ ```
986
+
987
+ **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.
988
+
989
+ **Pairs with `@classytic/primitives/state-machine`** — different layers:
990
+ - `defineStateMachine()` answers "is `from → to` legal in the model?" (compile-time table + early throw)
991
+ - `claim()` answers "did we win the transition vs concurrent writers?" (runtime null on race)
992
+
993
+ 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.
994
+
995
+ #### New types exported from `@classytic/repo-core/repository`
996
+
997
+ - `ClaimTransition` — `{ field?, from, to, where? }` argument shape for `claim` (`where` is the compound-CAS predicate slot — see below).
998
+ - `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).
999
+
1000
+ #### Added to `StandardRepo<TDoc>` — REQUIRED methods (not optional)
1001
+
1002
+ ```ts
1003
+ claim(
1004
+ id: string,
1005
+ transition: ClaimTransition,
1006
+ patch?: Partial<TDoc>,
1007
+ options?: WriteOptions,
1008
+ ): Promise<TDoc | null>;
1009
+
1010
+ claimVersion(
1011
+ id: string,
1012
+ transition: ClaimVersionTransition,
1013
+ update: Record<string, unknown>,
1014
+ options?: WriteOptions,
1015
+ ): Promise<TDoc | null>;
1016
+ ```
1017
+
1018
+ **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.
1019
+
1020
+ #### `ClaimTransition.where` — compound-CAS predicate
1021
+
1022
+ 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.
1023
+
1024
+ `ClaimTransition.where` AND-merges arbitrary predicates alongside the canonical id + state-field match:
1025
+
1026
+ ```ts
1027
+ const claimed = await repo.claim?.(runId, {
1028
+ from: 'waiting',
1029
+ to: 'running',
1030
+ where: {
1031
+ paused: { $ne: true },
1032
+ 'scheduling.retryAfter': { $lte: new Date() },
1033
+ },
1034
+ }, { lastHeartbeat: new Date() });
1035
+ ```
1036
+
1037
+ Cross-kit notes:
1038
+ - Mongokit: ANDed into the `findOneAndUpdate` filter.
1039
+ - SQL kits: ANDed into the `WHERE` clause (raw column literals accepted; portable Filter IR is compiled).
1040
+ - Prismakit: merged as additional keys on the `where` object.
1041
+
1042
+ 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."
1043
+
1044
+ 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.
1045
+
1046
+ #### `ClaimTransition.from` widened to `unknown | readonly unknown[]` — multi-source CAS
1047
+
1048
+ 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).
1049
+
1050
+ ```ts
1051
+ // "From any non-terminal state to voided"
1052
+ await repo.claim?.(id, { from: ['pending', 'approved', 'sent'], to: 'voided' });
1053
+ ```
1054
+
1055
+ Single-value `from` is unchanged (back-compatible). Array form is opt-in — pass an array to enable.
1056
+
1057
+ **`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.
1058
+
1059
+ #### Migration
1060
+
1061
+ Pre-0.4.0 callers wrote:
1062
+
1063
+ ```ts
1064
+ const claimed = await repo.findOneAndUpdate(
1065
+ { _id: id, status: 'waiting' },
1066
+ { $set: { status: 'running', lastHeartbeat: new Date() }, },
1067
+ );
1068
+ ```
1069
+
1070
+ Post-0.4.0:
1071
+
1072
+ ```ts
1073
+ const claimed = await repo.claim?.(id, { from: 'waiting', to: 'running' }, { lastHeartbeat: new Date() });
1074
+ ```
1075
+
1076
+ The old form keeps working — `claim()` is an additive optional method, not a rename.
1077
+
1078
+ ## [0.3.0] - 2026-04-29
1079
+
1080
+ ### Added — Aggregate pagination shapes
1081
+
1082
+ - `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.
1083
+ - `AnyPaginationResult<TDoc, TExtra>` — union over the three result shapes. Use as the input type to anything that converts repo results into HTTP envelopes.
1084
+
1085
+ ### Added — HTTP wire envelopes
1086
+
1087
+ 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.
1088
+
1089
+ - `OffsetPaginationResponse<TDoc, TExtra>` = `{ success: true } & OffsetPaginationResult<TDoc, TExtra>`
1090
+ - `KeysetPaginationResponse<TDoc, TExtra>` = same for keyset
1091
+ - `AggregatePaginationResponse<TDoc, TExtra>` = same for aggregate
1092
+ - `BareListResponse<TDoc>` = `{ success: true; docs: TDoc[] }` for endpoints that don't paginate
1093
+ - `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')`.
1094
+
1095
+ ### Added — `toCanonicalList()` runtime normalizer
1096
+
1097
+ ```ts
1098
+ import { toCanonicalList } from '@classytic/repo-core/pagination';
1099
+
1100
+ const result = await userRepo.getAll(query);
1101
+ reply.send(toCanonicalList(result)); // → PaginatedResponse<User>
1102
+
1103
+ reply.send(toCanonicalList([u1, u2])); // → BareListResponse<User>
1104
+ ```
1105
+
1106
+ 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.
1107
+
1108
+ **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.
1109
+
1110
+ ### Added — `isPaginatedResult()` type guard
1111
+
1112
+ 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.
1113
+
1114
+ ### Test delta
1115
+
1116
+ 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).
1117
+
1118
+ ### Added — `SchemaGenerator<TModel>` interface in `/schema`
1119
+
1120
+ 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.
1121
+
1122
+ - `SchemaGenerator<TModel = unknown>` — `(model, options?, context?) => CrudSchemas | Record<string, unknown>`.
1123
+ - `SchemaGeneratorContext` — resource-level context threaded at boot (`idField`, `resourceName`).
1124
+ - `isSchemaGenerator(value)` — runtime predicate (arity 1-3 functions). Conservative — doesn't invoke.
1125
+
1126
+ Each kit ships a compile-time conformance check (same playbook as mongokit's `RepositoryLike` conformance gate):
1127
+
1128
+ ```ts
1129
+ const _conformance: SchemaGenerator<Model<unknown>> = buildCrudSchemasFromModel;
1130
+ ```
1131
+
1132
+ Drift surfaces in the kit's typecheck immediately, before any consumer sees it.
1133
+
1134
+ 10 new tests in `tests/unit/schema/generator.test.ts`. Total repo-core: 293 → 303.
1135
+
1136
+ ### Added — `errors` module: canonical wire + throwable error contract
1137
+
1138
+ `@classytic/repo-core/errors` is now the single source of truth for error contracts across the org. Two complementary shapes:
1139
+
1140
+ - **`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).
1141
+ - **`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.
1142
+ - **`ErrorDetail`** — single field-scoped error (path / code / message). `ErrorContract.details` is `ReadonlyArray<ErrorDetail>`.
1143
+ - **`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'`).
1144
+ - **`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.
1145
+ - **`statusToErrorCode(status)`** — well-known HTTP status → canonical code. Conservative mapping; unknown statuses fall through to `'internal_error'` so domain handlers explicitly opt in.
1146
+
1147
+ 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.
1148
+
1149
+ 14 new tests in `tests/unit/errors/contract.test.ts`. Total repo-core: 279 → 293.
1150
+
1151
+ ### Added — `tenant` subpath (canonical home for tenant scope contract)
1152
+
1153
+ New subpath `@classytic/repo-core/tenant` ships:
1154
+ - `TenantConfig` — static config (`strategy`, `enabled`, `tenantField`, `fieldType`, `ref`, `contextKey`, `required`, `resolve`).
1155
+ - `TenantStrategy = 'field' | 'none' | 'custom'`, `TenantFieldType = 'objectId' | 'string'`.
1156
+ - `ResolvedTenantConfig` — the resolved-with-defaults shape returned by `resolveTenantConfig`.
1157
+ - `DEFAULT_TENANT_CONFIG` — sensible org-wide defaults (`tenantField: 'organizationId'`, `fieldType: 'objectId'`, `ref: 'organization'`, `required: true`).
1158
+ - `resolveTenantConfig(config?)` — normaliser; validates `'custom'` strategy requires `resolve`.
1159
+
1160
+ 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.
1161
+
1162
+ **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`.
1163
+
1164
+ 14 new tests in `tests/unit/tenant/resolve.test.ts` (ported from primitives' suite). Total repo-core: 265 → 279 tests.
1165
+
1166
+ ### Added — schema-builder vocabulary
1167
+
1168
+ - **`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.
1169
+ - **`SchemaBuilderOptions.response`** with `omitFields?: string[]` — response-schema overrides. Drops extra fields from the response shape without marking them globally hidden.
1170
+ - **`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.
1171
+ - **`FieldRule.hidden?: boolean`** — strips the field from the response shape. Distinct from `systemManaged` (request-body concern). Use for passwords, secrets, internal scoring.
1172
+ - **`collectFieldsToOmit(options, 'response')`** — third purpose alongside `'create'` / `'update'`. Implements the response policy (only `hidden` + `excludeFields` + `response.omitFields`).
1173
+
1174
+ These are the contracts mongokit 3.12 implements, arc 2.12's MongooseAdapter consumes, and any future kit (sqlitekit, prismakit) inherits for free.
1175
+
1176
+ ### Hardened — `RepositoryBase` plugin-shape validation
1177
+
1178
+ `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:
1179
+
1180
+ ```
1181
+ [repo-core] Repository "Foo": plugin at index 0 has wrong type.
1182
+ Expected a function or { name, apply(repo) } object — got string 'organizationId'.
1183
+ Common cause: `new Repository(Model, [tenantField], opts)` — second argument must be a plugins array.
1184
+ ```
1185
+
1186
+ Lock-in: `tests/unit/repository/base-plugin-validation.test.ts` (6 cases covering string/null/object-without-apply/function/object/post-construction `use()` paths).
1187
+
1188
+ ### Migration — mongokit 4.0, arc 2.12, arc-next 0.6
1189
+
1190
+ Three downstream changes drop their local copies and import directly:
1191
+
1192
+ 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.)
1193
+ 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.
1194
+ 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.
1195
+
1196
+ No breaking changes inside `@classytic/repo-core` itself — purely additive.
1197
+
1198
+ ## [0.2.0] - 2026-04-22
1199
+
1200
+ ### Added — Update IR (portable write-side primitive)
1201
+
1202
+ - **New `@classytic/repo-core/update` subpath.** The write-side counterpart to `@classytic/repo-core/filter`. Plugins and arc's infrastructure stores compose an `UpdateSpec` once; each kit compiles it to its native shape.
1203
+ - **Types:** `UpdateSpec` (tagged union on `op: 'update'`, four buckets: `set` / `unset` / `setOnInsert` / `inc`), `UpdateInput` (union of `UpdateSpec` | kit-native `Record<string, unknown>` | Mongo pipeline `Record<string, unknown>[]`).
1204
+ - **Builders:** `update({ set, unset, setOnInsert, inc })` (root), `setFields`, `unsetFields(...f)`, `setOnInsertFields`, `incFields`, `combineUpdates(...specs)` (later-wins merge, `unset` de-duplicates).
1205
+ - **Guards:** `isUpdateSpec` (routes portable IR to the compiler), `isUpdatePipeline` (lets SQL kits short-circuit with `UnsupportedOperationError`).
1206
+ - **Compilers:** `compileUpdateSpecToMongo(spec)` emits `{ $set, $unset, $setOnInsert, $inc }`. `compileUpdateSpecToSql(spec)` emits a `SqlUpdatePlan` with `data` / `unset` / `inc` / `insertDefaults` buckets, leaving SQL generation (quoting, `ON CONFLICT`, parameter binding) to the kit.
1207
+ - **`StandardRepo.findOneAndUpdate` + `updateMany` widened to `UpdateInput`.** Accepts all three forms — portable `UpdateSpec`, kit-native record, Mongo aggregation pipeline. Kits dispatch with `isUpdateSpec`. The existing raw-record and pipeline paths remain unchanged; the IR is purely additive so consumers don't need to migrate. Arc's infrastructure stores (outbox, idempotency, audit) will switch to the IR over a subsequent release to close the "Mongo-shaped store" gap flagged in the April 2026 cross-surface review.
1208
+
1209
+ **Motivation (Arc April 2026 review):** arc's `EventOutbox`, `IdempotencyStore`, and `AuditStore` adapters use Mongo operator records (`$set`, `$inc`, `$unset`, `$setOnInsert`, `$or`, `$lte`, ...) directly against `RepositoryLike.findOneAndUpdate`. That works on mongokit but fails on sqlitekit — whose `findOneAndUpdate` treats `data` as flat column overwrites and would literally set a column named `$set`. The Update IR closes the gap without forcing every kit to ship its own Mongo-operator compatibility layer.
1210
+
1211
+ **Rationale for scope:** the IR covers the subset every backend supports (atomic set / unset / inc / insert-default). Kit-native features — Mongo `$push`/`$pull`/`$addToSet`, aggregation pipeline updates, Postgres `jsonb_set`, SQL `CASE` expressions — stay on the kit-native path via `UpdateInput`'s raw-record and pipeline forms. No lowest-common-denominator bloat; no feature loss for kits that already offer more.
1212
+
1213
+ **Test delta**: 193 → 230 tests (37 new across `tests/unit/update/builders`, `/guard`, `/compile`).
1214
+
1215
+ ### Changed — breaking: `StandardRepo` write signatures
1216
+
1217
+ `StandardRepo.findOneAndUpdate(filter, update, ...)` and `updateMany(filter, data, ...)` — the second parameter is now typed `UpdateInput` (was `Record<string, unknown> | Record<string, unknown>[]` and `Record<string, unknown>` respectively). Every call site that compiled against 0.1.0 keeps compiling: `Record<string, unknown>` and `Record<string, unknown>[]` are subtypes of `UpdateInput`. The break is on the **implementer** side — any kit that declared only the old parameter type no longer structurally satisfies `StandardRepo` under strict contravariance. mongokit 3.11.0 and sqlitekit 0.1.1 already ship with the widened signatures; third-party kits need to widen before bumping their `@classytic/repo-core` peer dep.
1218
+
1219
+ ### Changed — breaking: `updateMany` + `deleteMany` promoted to required members of `StandardRepo`
1220
+
1221
+ Both methods were optional (`updateMany?` / `deleteMany?`) in 0.1.0; they're required in 0.2.0. Rationale: every real backend has a native bulk-update and bulk-delete primitive, and arc's infrastructure stores (outbox, idempotency, audit cleanup) assume both are callable without feature-detection. Leaving them optional invited the "forgot to wire `batchOperationsPlugin`" runtime `TypeError` footgun that earlier releases of mongokit shipped — with the promotion, the type system catches missing implementations at the kit boundary.
1222
+
1223
+ **Impact**:
1224
+ - **Kits**: any kit declaring `class FooRepo<T> implements StandardRepo<T>` must provide `updateMany` and `deleteMany` or fail to compile. mongokit 3.11.0 and sqlitekit 0.1.1 both ship these as class primitives, so their conformance stays green.
1225
+ - **Consumers of `RepositoryLike<T> = MinimalRepo<T> & Partial<StandardRepo<T>>`** (arc's pattern) are unaffected — `Partial` reimposes optionality for feature detection at the arc adapter boundary. `if (repo.updateMany)` guards keep working.
1226
+ - `bulkWrite` stays optional — the mongoose-shaped `BulkWriteOperation` has no clean SQL analogue, and every kit would ship an uninteresting fan-out wrapper otherwise.
1227
+
1228
+ ### Naming — `UpdateInput` collision with mongokit
1229
+
1230
+ `@classytic/repo-core/update` exports `UpdateInput` as the union `UpdateSpec | Record<string, unknown> | Record<string, unknown>[]`. `@classytic/mongokit` currently **also** exports a type named `UpdateInput<TDoc> = Partial<Omit<TDoc, '_id' | 'createdAt' | '__v'>>` — a completely different, document-typed shape used by `repo.update(id, data)`. If you write `import { UpdateInput } from '@classytic/mongokit'`, you get mongokit's generic; if you write `import type { UpdateInput } from '@classytic/repo-core/update'`, you get the union. Both names may appear in the same consumer file — import at least one with an alias (`import type { UpdateInput as UpdatePatch } from '@classytic/mongokit'`). mongokit will rename its local type in a follow-up release to close the collision permanently.
1231
+
1232
+ ### Added
1233
+ - Phase 0 scaffold: package.json, tsconfig, tsdown, biome, vitest (4-tier), knip
1234
+ - `@classytic/repo-core/hooks` — `HOOK_PRIORITY` constants + `HookPriority` type
1235
+ - `@classytic/repo-core/operations` — `CORE_OP_REGISTRY`, `extendRegistry`, `listOperations`, `mutatingOperations`, `readOperations`, `operationsByPolicyKey`, `describe`; 17 core ops, driver-free
1236
+ - `@classytic/repo-core/errors` — `HttpError`, `createError`, `isHttpError`, `conservativeMongoIsDuplicateKey`, `toDuplicateKeyHttpError`
1237
+ - `@classytic/repo-core/pagination` — `encodeCursor`/`decodeCursor` (URL-safe base64, mongokit ≤3.x compat), `validateCursorSort`, `validateCursorVersion`, `normalizeSort`, `validateKeysetSort`, `invertSort`, `getPrimaryField`, `validateLimit`, `validatePage`, `shouldWarnDeepPagination`, `calculateSkip`, `calculateTotalPages`
1238
+ - INFRA.md tracking doc + 4-tier test structure per monorepo testing-infrastructure.md
1239
+ - 74 unit tests across 6 files, full suite runs in ~360 ms
1240
+ - `@classytic/repo-core/filter` — Filter IR types (`Filter` discriminated union), combinators (`eq`, `ne`, `gt`/`gte`/`lt`/`lte`, `in_`/`anyOf`, `nin`/`noneOf`, `like`, `regex`, `exists`, `and`, `or`, `not`/`invert`), constants (`TRUE`, `FALSE`), runtime guard (`isFilter`), traversal (`walkFilter`, `mapFilter`, `collectFields`), in-memory evaluator (`matchFilter`, `asPredicate`). Boolean absorbing/identity elimination baked in.
1241
+ - `@classytic/repo-core/repository` — arc-aligned contract types: `MinimalRepo<TDoc>` (5-method floor), `StandardRepo<TDoc>` (recommended surface), option/result types (`QueryOptions`, `WriteOptions`, `DeleteOptions`, `FindOneAndUpdateOptions`, `DeleteResult`, `DeleteManyResult`, `UpdateManyResult`, `PaginationParams`), `RepositorySession`, `InferDoc<R>`.
1242
+ - 43 additional unit tests (builders, walk, match) bringing total to 117.
1243
+ - **Filter IR expansion** — sugar builders: `between`, `startsWith`, `endsWith`, `contains`, `iEq`, `isNull`, `isNotNull` (desugar to existing ops); new `raw` escape-hatch op for driver-native fragments (e.g. pgvector `<=>`, SQLite JSON1 path, Mongo `$geoWithin`).
1244
+ - `@classytic/repo-core/query-parser` — URL → `ParsedQuery<Filter>` driver-agnostic parser. Bracket grammar (`field[gte]=18`, `field[in]=a,b`, `field[contains]=text`). Shared types (`ParsedQuery`, `ParsedSort`, `ParsedSelect`, `ParsedPopulate`, `QueryParserOptions`, `BracketOperator`) consumed identically by every kit and both frontends (arc-next, fluid).
1245
+ - 36 more unit tests covering sugar builders + QueryParser. Total: **153 tests, 11 files, green on vitest 4**.
1246
+ - Upgraded to **vitest 4.1**, **typescript 6.0**, **biome 2.4.12**. Vitest config migrated to new `pool` / `fileParallelism` top-level API.
1247
+ - **M3 landed: `RepositoryBase` abstract class** at `@classytic/repo-core/repository`. Owns `HookEngine`, plugin installation, context builder, `_emitAfter` / `_emitError`, and the `_cachedValue` cache-short-circuit helper. Kits extend it instead of reinventing the hook layer.
1248
+ - **M4 landed: 5 portable plugins** — `@classytic/repo-core/plugins/timestamp`, `/multi-tenant`, `/soft-delete`, `/audit`, `/cache`. All Filter-IR-native. Multi-tenant + soft-delete inject scope via `and(existing, eq(...))` / `and(existing, isNull(...))` — no Mongo-specific `$`-operators. Cache includes pluggable `CacheAdapter` interface + reference `createMemoryCacheAdapter`.
1249
+ - **Tightened the scope rule for plugins** — skipped observability / validation-chain / field-filter / cascade / custom-id. Rule: a plugin lives in repo-core only if every backend needs it AND no backend provides it natively. The skipped ones are either arc's job (field-filter), host cookbook (observability, custom-id), zod's job (validation-chain), or SQL-native (cascade).
1250
+ - **`StandardRepo.withTransaction` signature fixed** — was `(session) => T`, mongoose leak. Now `(txRepo: this) => T` — caller writes `await txRepo.create(...)`, never touches session. SQL / Prisma kits bind the tx connection to a new repo; mongokit returns a session-threaded proxy.
1251
+ - `QueryOptions.session` re-documented as mongoose-specific.
1252
+ - `RepositoryBase.on` / `off` now generic over listener-data type so typed plugins (`(ctx: RepositoryContext) => void`) don't need casts.
1253
+ - **HookEngine** exposed at `@classytic/repo-core/hooks` — `DEFAULT_LISTENER_PRIORITY`, `HookEngine` class, `HookListener`, `HookMode`, `PrioritizedHook`, `EventPhase` types.
1254
+ - **Context subpath** at `@classytic/repo-core/context` — `RepositoryContext` type exposed independently for plugin authors.
1255
+ - **`HookEngine.listeners()`** — read-only snapshot of the listener registry (frozen buckets). Lets kits expose a back-compat `_hooks: Map<event, PrioritizedHook[]>` getter without handing out mutable internal state. Used by mongokit 3.10's read-through `_hooks` shim.
1256
+ - **`RepositoryBase._buildContext` always awaits `before:*` hooks** regardless of engine mode — policy plugins must mutate context synchronously before the driver call fires. After- and error-hooks still honor `hooks: 'sync'` for fire-and-forget observability. Previous behavior (fire-and-forget in sync mode) would let driver calls race ahead of tenant/soft-delete scope injection; tracked as a latent bug surfaced by the mongokit 3.10 migration.
1257
+
1258
+ ### Changed — plugins move out, primitives move in
1259
+
1260
+ - **All 5 plugins deleted from `@classytic/repo-core/plugins/*`** — `timestamp`, `soft-delete`, `multi-tenant`, `audit`, `cache` no longer ship from this package. Rationale: end-users install a kit, never repo-core directly, so plugins belong in the namespace users actually import. Each kit (mongokit, sqlitekit, pgkit, prismakit) owns its own plugin implementations, which lets each use driver-native features (mongoose native timestamps, SQLite triggers, Postgres `now()`, Prisma `@default`) instead of a lowest-common-denominator JS emulation.
1261
+ - **New `@classytic/repo-core/cache` subpath** — the plugin-composition primitives that every kit's cachePlugin shares:
1262
+ - `CacheAdapter` interface (get / set / del / delByPattern)
1263
+ - `stableStringify` — deterministic JSON keying for cache buckets
1264
+ - `createMemoryCacheAdapter` — reference in-memory adapter for tests + single-process apps
1265
+ - **New scope helpers in `@classytic/repo-core/filter`** — `buildTenantScope(existing, tenantField, tenantId)` and `mergeScope(existing, scope)`. Every policy plugin (multi-tenant / soft-delete / org-boundary) AND-s a predicate into an existing filter while handling three shapes (undefined / Filter IR / flat record). Lifted into `/filter` so each kit's plugin composes the same merge semantics.
1266
+ - **Architectural principle codified** — "Repo-core is invisible infrastructure. End-users install a kit; repo-core exists for kit authors." See INFRA.md §3.
1267
+
1268
+ ### Arc integration review — 2026-04-19
1269
+
1270
+ Changes landed in response to the Arc 2.10 maintainer's end-to-end integration report (see `ARC_INTEGRATION_REPORT.md`). All three non-blocking enhancements adopted; sqlitekit-side blockers are tracked on the sqlitekit package, not here.
1271
+
1272
+ - **`OffsetPaginationResult<TDoc, TExtra>` + `KeysetPaginationResult<TDoc, TExtra>`** — added an optional second generic so kits can surface typed extras alongside the core envelope (mongokit emits `warning?: string` on deep-page reads, pgkit could surface `queryPlan`, sqlitekit could surface vacuum hints). Defaults to `Record<string, never>` so existing consumers (`OffsetPaginationResult<User>`) see zero behavioral change. Both types also export their core interface (`OffsetPaginationResultCore` / `KeysetPaginationResultCore`) for StandardRepo contract references. The `method` discriminant carries through the intersection so `if (result.method === 'offset')` narrowing keeps working.
1273
+ - **`HOOK_EVENTS` constant + `HookEventName` type** at `@classytic/repo-core/hooks` — canonical string-constant registry for every `before:* / after:* / error:*` event across the MinimalRepo + StandardRepo op set (17 ops × 3 phases = 51 events). Plugin authors subscribe via `HOOK_EVENTS.BEFORE_CREATE` instead of raw strings so typos become compile errors instead of silent no-ops. Kits with additional native ops (mongokit's `aggregate` / `bulkWrite`) compose their own extended constant on top. Cross-kit plugins written against `HOOK_EVENTS` work identically on every kit for the shared op set.
1274
+ - **`CacheAdapter.clear?(pattern?)`** replaces the previous `delByPattern?(pattern)` — aligns with mongokit's `CacheAdapter` and Arc's `CacheStore` so one Redis / KV / Memcached implementation plugs into every consumer. `clear()` with no argument wipes everything; `clear('prefix:*')` is glob-matched. `createMemoryCacheAdapter` reference impl updated. Breaking change for anything calling `adapter.delByPattern` directly — sqlitekit's cache plugin (the only in-tree caller) migrated in the same commit.
1275
+ - **`CacheAdapter.delete(key)`** — renamed from `del(key)` for consistency with JavaScript's native `Map.delete` / `Set.delete`, `MinimalRepo.delete(id)` in this same package, arc's `RepositoryLike.delete`, and every higher-level cache library (Keyv, etc.). Redis clients keep their own `.del()` — the adapter implementation translates. Applied across `@classytic/repo-core/cache` (interface + `createMemoryCacheAdapter`) and `@classytic/mongokit` (its own `CacheAdapter` type + `createMemoryCache` reference impl + every test adapter). The rename arrived **before first publish**, so no migration cost for external consumers — it's simply the shipping name.
1276
+ - **TTL unit stays seconds** (`ttlSeconds`) — matches Redis `SET EX seconds` and mongokit's existing `ttl` semantics. Arc's `ttlMs` was the outlier; the Arc team agreed to align on seconds.
1277
+ - **Sync-or-async return types preserved** on `CacheAdapter` — memory-backed adapters return synchronously without a microtask hop; Redis adapters return Promises. Consumers `await` either way at no runtime cost.
1278
+
1279
+ **Test delta**: 153 → 177 tests (24 new covering HOOK_EVENTS exhaustiveness, pagination TExtra narrowing, and CacheAdapter + stableStringify round-trip).
1280
+
1281
+ ### Unified `withTransaction` contract — mongokit caught up
1282
+
1283
+ The `StandardRepo.withTransaction` contract at `@classytic/repo-core/repository` has always specified `fn: (txRepo: this) => Promise<T>` — the bound-tx shape sqlitekit has implemented since 0.1. Mongokit 3.9 deviated from the contract and passed a raw `ClientSession` instead. Mongokit 3.10 fixes that: its `withTransaction` now hands over a session-threaded proxy repository matching the canonical signature. Cross-kit plugins and apps that depend on `StandardRepo.withTransaction` now work identically against mongokit and sqlitekit — one contract, no kit-specific branches.
1284
+
1285
+ The contract docstring in `src/repository/types.ts` was already accurate; this is the mongokit-side implementation catching up. See `@classytic/mongokit` 3.10 release notes for the migration diff — this is a **breaking change for mongokit 3.x users** but repo-core's shape is unchanged.
1286
+
1287
+ ### Consumed by
1288
+
1289
+ - **`@classytic/mongokit`** — `Repository extends RepositoryBase`; hook engine, plugin-order validator, and `HOOK_PRIORITY` sourced from repo-core. Ships its own Mongo-optimized plugins (unchanged from 3.9).
1290
+ - **`@classytic/sqlitekit`** — extends `RepositoryBase`; ships its own SQLite-optimized plugins at `@classytic/sqlitekit/plugins/{timestamp,soft-delete,multi-tenant,audit,cache}`. Imports `CacheAdapter` + `stableStringify` + `buildTenantScope` from repo-core.