@classytic/repo-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cache/index.d.mts +4 -0
  5. package/dist/cache/index.mjs +3 -0
  6. package/dist/cache/memory-adapter.d.mts +7 -0
  7. package/dist/cache/memory-adapter.mjs +37 -0
  8. package/dist/cache/stable-stringify.d.mts +15 -0
  9. package/dist/cache/stable-stringify.mjs +19 -0
  10. package/dist/cache/types.d.mts +59 -0
  11. package/dist/context/index.d.mts +2 -0
  12. package/dist/context/index.mjs +0 -0
  13. package/dist/context/types.d.mts +24 -0
  14. package/dist/errors/create-error.d.mts +19 -0
  15. package/dist/errors/create-error.mjs +23 -0
  16. package/dist/errors/duplicate-key.d.mts +38 -0
  17. package/dist/errors/duplicate-key.mjs +57 -0
  18. package/dist/errors/index.d.mts +4 -0
  19. package/dist/errors/index.mjs +3 -0
  20. package/dist/errors/types.d.mts +37 -0
  21. package/dist/filter/builders.d.mts +60 -0
  22. package/dist/filter/builders.mjs +172 -0
  23. package/dist/filter/guard.d.mts +13 -0
  24. package/dist/filter/guard.mjs +34 -0
  25. package/dist/filter/index.d.mts +7 -0
  26. package/dist/filter/index.mjs +6 -0
  27. package/dist/filter/match.d.mts +12 -0
  28. package/dist/filter/match.mjs +91 -0
  29. package/dist/filter/scope.d.mts +31 -0
  30. package/dist/filter/scope.mjs +54 -0
  31. package/dist/filter/types.d.mts +143 -0
  32. package/dist/filter/walk.d.mts +24 -0
  33. package/dist/filter/walk.mjs +77 -0
  34. package/dist/hooks/engine.d.mts +48 -0
  35. package/dist/hooks/engine.mjs +101 -0
  36. package/dist/hooks/events.d.mts +95 -0
  37. package/dist/hooks/events.mjs +93 -0
  38. package/dist/hooks/index.d.mts +5 -0
  39. package/dist/hooks/index.mjs +4 -0
  40. package/dist/hooks/priority.d.mts +23 -0
  41. package/dist/hooks/priority.mjs +21 -0
  42. package/dist/hooks/types.d.mts +37 -0
  43. package/dist/lookup/index.d.mts +2 -0
  44. package/dist/lookup/index.mjs +0 -0
  45. package/dist/lookup/types.d.mts +170 -0
  46. package/dist/operations/index.d.mts +3 -0
  47. package/dist/operations/index.mjs +2 -0
  48. package/dist/operations/registry.d.mts +41 -0
  49. package/dist/operations/registry.mjs +140 -0
  50. package/dist/operations/types.d.mts +49 -0
  51. package/dist/pagination/cursor.d.mts +44 -0
  52. package/dist/pagination/cursor.mjs +150 -0
  53. package/dist/pagination/index.d.mts +5 -0
  54. package/dist/pagination/index.mjs +4 -0
  55. package/dist/pagination/keyset.d.mts +25 -0
  56. package/dist/pagination/keyset.mjs +61 -0
  57. package/dist/pagination/offset.d.mts +26 -0
  58. package/dist/pagination/offset.mjs +47 -0
  59. package/dist/pagination/types.d.mts +136 -0
  60. package/dist/query-parser/coerce.d.mts +16 -0
  61. package/dist/query-parser/coerce.mjs +73 -0
  62. package/dist/query-parser/index.d.mts +4 -0
  63. package/dist/query-parser/index.mjs +3 -0
  64. package/dist/query-parser/parse-url.d.mts +7 -0
  65. package/dist/query-parser/parse-url.mjs +224 -0
  66. package/dist/query-parser/types.d.mts +104 -0
  67. package/dist/repository/base.d.mts +90 -0
  68. package/dist/repository/base.mjs +111 -0
  69. package/dist/repository/index.d.mts +5 -0
  70. package/dist/repository/index.mjs +3 -0
  71. package/dist/repository/plugin-types.d.mts +27 -0
  72. package/dist/repository/plugin-types.mjs +45 -0
  73. package/dist/repository/types.d.mts +470 -0
  74. package/dist/schema/field-rules.d.mts +62 -0
  75. package/dist/schema/field-rules.mjs +110 -0
  76. package/dist/schema/index.d.mts +3 -0
  77. package/dist/schema/index.mjs +2 -0
  78. package/dist/schema/types.d.mts +138 -0
  79. package/dist/testing/conformance.d.mts +6 -0
  80. package/dist/testing/conformance.mjs +481 -0
  81. package/dist/testing/index.d.mts +3 -0
  82. package/dist/testing/index.mjs +2 -0
  83. package/dist/testing/types.d.mts +113 -0
  84. package/package.json +130 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
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
+ ## [Unreleased]
8
+
9
+ ### Added
10
+ - Phase 0 scaffold: package.json, tsconfig, tsdown, biome, vitest (4-tier), knip
11
+ - `@classytic/repo-core/hooks` — `HOOK_PRIORITY` constants + `HookPriority` type
12
+ - `@classytic/repo-core/operations` — `CORE_OP_REGISTRY`, `extendRegistry`, `listOperations`, `mutatingOperations`, `readOperations`, `operationsByPolicyKey`, `describe`; 17 core ops, driver-free
13
+ - `@classytic/repo-core/errors` — `HttpError`, `createError`, `isHttpError`, `conservativeMongoIsDuplicateKey`, `toDuplicateKeyHttpError`
14
+ - `@classytic/repo-core/pagination` — `encodeCursor`/`decodeCursor` (URL-safe base64, mongokit ≤3.x compat), `validateCursorSort`, `validateCursorVersion`, `normalizeSort`, `validateKeysetSort`, `invertSort`, `getPrimaryField`, `validateLimit`, `validatePage`, `shouldWarnDeepPagination`, `calculateSkip`, `calculateTotalPages`
15
+ - INFRA.md tracking doc + 4-tier test structure per monorepo testing-infrastructure.md
16
+ - 74 unit tests across 6 files, full suite runs in ~360 ms
17
+ - `@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.
18
+ - `@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>`.
19
+ - 43 additional unit tests (builders, walk, match) bringing total to 117.
20
+ - **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`).
21
+ - `@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).
22
+ - 36 more unit tests covering sugar builders + QueryParser. Total: **153 tests, 11 files, green on vitest 4**.
23
+ - Upgraded to **vitest 4.1**, **typescript 6.0**, **biome 2.4.12**. Vitest config migrated to new `pool` / `fileParallelism` top-level API.
24
+ - **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.
25
+ - **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`.
26
+ - **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).
27
+ - **`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.
28
+ - `QueryOptions.session` re-documented as mongoose-specific.
29
+ - `RepositoryBase.on` / `off` now generic over listener-data type so typed plugins (`(ctx: RepositoryContext) => void`) don't need casts.
30
+ - **HookEngine** exposed at `@classytic/repo-core/hooks` — `DEFAULT_LISTENER_PRIORITY`, `HookEngine` class, `HookListener`, `HookMode`, `PrioritizedHook`, `EventPhase` types.
31
+ - **Context subpath** at `@classytic/repo-core/context` — `RepositoryContext` type exposed independently for plugin authors.
32
+ - **`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.
33
+ - **`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.
34
+
35
+ ### Changed — plugins move out, primitives move in
36
+
37
+ - **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.
38
+ - **New `@classytic/repo-core/cache` subpath** — the plugin-composition primitives that every kit's cachePlugin shares:
39
+ - `CacheAdapter` interface (get / set / del / delByPattern)
40
+ - `stableStringify` — deterministic JSON keying for cache buckets
41
+ - `createMemoryCacheAdapter` — reference in-memory adapter for tests + single-process apps
42
+ - **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.
43
+ - **Architectural principle codified** — "Repo-core is invisible infrastructure. End-users install a kit; repo-core exists for kit authors." See INFRA.md §3.
44
+
45
+ ### Arc integration review — 2026-04-19
46
+
47
+ 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.
48
+
49
+ - **`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.
50
+ - **`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.
51
+ - **`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.
52
+ - **`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.
53
+ - **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.
54
+ - **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.
55
+
56
+ **Test delta**: 153 → 177 tests (24 new covering HOOK_EVENTS exhaustiveness, pagination TExtra narrowing, and CacheAdapter + stableStringify round-trip).
57
+
58
+ ### Unified `withTransaction` contract — mongokit caught up
59
+
60
+ 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.
61
+
62
+ 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.
63
+
64
+ ### Consumed by
65
+
66
+ - **`@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).
67
+ - **`@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Classytic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @classytic/repo-core
2
+
3
+ **Driver-agnostic repository primitives.** Hooks, Filter IR, operations registry, pagination, URL query parsing, cache contract — the shared foundation for `@classytic/mongokit`, `@classytic/sqlitekit`, and future `@classytic/pgkit` / `@classytic/prismakit`.
4
+
5
+ Repo-core is **infrastructure for kit authors.** End-users install a kit (mongokit / sqlitekit) and import their full API from that one namespace. Repo-core is what each kit's runtime is built on — you typically won't import it directly in application code.
6
+
7
+ ## Design principles
8
+
9
+ - **ESM only.** `.mjs` + `.d.mts` output. Node 22+.
10
+ - **No root barrel.** Every public surface is its own subpath in `exports`. Import directly from where the symbol lives; unused modules never enter your dep graph.
11
+ - **Zero runtime dependencies.** No driver imports anywhere in `src/`.
12
+ - **Tree-shakeable by construction.** `unbundle: true` in tsdown — 1:1 src→dist, no shared chunks.
13
+ - **No plugins ship here.** Each kit owns its own plugin implementations so they can use driver-native features (mongoose's built-in timestamps, SQLite triggers, Postgres `now()`, Prisma `@default`). Repo-core provides the primitives kits compose: `CacheAdapter`, `stableStringify`, `buildTenantScope`, `HOOK_PRIORITY`, the hook engine, Filter IR.
14
+
15
+ ## Subpaths
16
+
17
+ ```ts
18
+ // Hook engine + priority constants + canonical event names. HOOK_EVENTS turns
19
+ // raw strings into compile-checked constants so typos fail at build time.
20
+ import { HOOK_EVENTS, HOOK_PRIORITY, HookEngine } from '@classytic/repo-core/hooks';
21
+
22
+ // Abstract repository base + MinimalRepo / StandardRepo contracts + plugin types.
23
+ import { RepositoryBase, type MinimalRepo, type Plugin } from '@classytic/repo-core/repository';
24
+
25
+ // Driver-agnostic filter AST — combinators (eq/and/or/in/like/between/...) + walk/match + scope helpers.
26
+ import { and, eq, gte, in_, like, buildTenantScope, matchFilter } from '@classytic/repo-core/filter';
27
+
28
+ // URL → ParsedQuery grammar. Backend frameworks (Express/Arc/Fastify) parse req.query here.
29
+ import { parseUrl } from '@classytic/repo-core/query-parser';
30
+
31
+ // Pagination primitives — cursor codec, keyset helpers, offset math.
32
+ import { encodeCursor, decodeCursor, validateKeysetSort } from '@classytic/repo-core/pagination';
33
+
34
+ // Cache plumbing — the CacheAdapter interface every kit's cachePlugin writes against.
35
+ import { type CacheAdapter, stableStringify, createMemoryCacheAdapter } from '@classytic/repo-core/cache';
36
+
37
+ // HTTP error envelope + duplicate-key contract.
38
+ import { createError, conservativeMongoIsDuplicateKey } from '@classytic/repo-core/errors';
39
+
40
+ // Operation registry (for arc-level policy dispatch + doc generation).
41
+ import { CORE_OP_REGISTRY, describe } from '@classytic/repo-core/operations';
42
+
43
+ // Repository hook context type (for plugin authors).
44
+ import type { RepositoryContext } from '@classytic/repo-core/context';
45
+ ```
46
+
47
+ **There is no `.` / root entry.** Import from the exact subpath — that's the contract that keeps tree-shaking honest.
48
+
49
+ ## Example: a kit's repository extends `RepositoryBase`
50
+
51
+ ```ts
52
+ import { RepositoryBase } from '@classytic/repo-core/repository';
53
+ import { compileFilter } from './my-kit-compiler.js';
54
+
55
+ export class MyKitRepository<T> extends RepositoryBase {
56
+ // Kit-specific CRUD methods. Each routes through `_buildContext` → plugins
57
+ // → native driver call → `_emitAfter` so every plugin (timestamp, cache,
58
+ // multi-tenant, audit) composes identically across kits.
59
+ async getAll(params: {...}) {
60
+ const context = await this._buildContext('getAll', params);
61
+ const cached = this._cachedValue(context);
62
+ if (cached) return cached;
63
+ const where = compileFilter(context.filters);
64
+ const result = await this.driver.query(where);
65
+ await this._emitAfter('getAll', context, result);
66
+ return result;
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Example: URL → ParsedQuery → any kit
72
+
73
+ The URL grammar is identical across every kit. Frontends emit one URL; swapping the backend DB doesn't change a single query string.
74
+
75
+ ```ts
76
+ import { parseUrl } from '@classytic/repo-core/query-parser';
77
+
78
+ app.get('/users', async (req, res) => {
79
+ const parsed = parseUrl(req.query, {
80
+ allowedFilterFields: ['email', 'role', 'active', 'createdAt'],
81
+ fieldTypes: { active: 'boolean', createdAt: 'date' },
82
+ maxLimit: 200,
83
+ });
84
+ // Works identically against any kit's repository:
85
+ // const page = await sqliteUserRepo.getAll(parsed);
86
+ // const page = await mongoUserRepo.getAll(parsed);
87
+ res.json(await userRepo.getAll(parsed));
88
+ });
89
+ ```
90
+
91
+ URL grammar:
92
+
93
+ | URL | Filter IR produced |
94
+ |---------------------------------------|-----------------------------------------------|
95
+ | `?status=active` | `eq('status', 'active')` |
96
+ | `?age[gte]=18&age[lt]=65` | `and(gte('age', 18), lt('age', 65))` |
97
+ | `?role[in]=admin,editor` | `in_('role', ['admin', 'editor'])` |
98
+ | `?name[contains]=john` | `like('name', '%john%')` |
99
+ | `?price[between]=10,100` | `and(gte(...), lte(...))` |
100
+ | `?deletedAt[exists]=false` | `isNull('deletedAt')` |
101
+ | `?sort=-createdAt,name` | `{ createdAt: -1, name: 1 }` (on `.sort`) |
102
+ | `?select=name,email,-password` | `{ name: 1, email: 1, password: 0 }` |
103
+ | `?page=2&limit=50` | pagination fields on ParsedQuery |
104
+ | `?after=eyJ2Ij...` | opaque keyset cursor |
105
+
106
+ ## Plugin-friendly event names
107
+
108
+ ```ts
109
+ import { HOOK_EVENTS, HOOK_PRIORITY } from '@classytic/repo-core/hooks';
110
+
111
+ // Cross-kit plugin — works identically on mongokit, sqlitekit, pgkit, prismakit.
112
+ export function stampOrgId(orgId: string): Plugin {
113
+ return {
114
+ name: 'stamp-org-id',
115
+ apply(repo) {
116
+ repo.on(HOOK_EVENTS.BEFORE_CREATE, (ctx) => {
117
+ if (!ctx.data?.organizationId) ctx.data = { ...ctx.data, organizationId: orgId };
118
+ }, { priority: HOOK_PRIORITY.POLICY });
119
+ },
120
+ };
121
+ }
122
+ ```
123
+
124
+ Typos like `'before:craete'` become compile errors. Subscribing to an event a given kit doesn't emit is a silent no-op (that's how the hook engine works), so a plugin can safely wire listeners for the full standard set.
125
+
126
+ ## Typed result extras
127
+
128
+ ```ts
129
+ import type { OffsetPaginationResult } from '@classytic/repo-core/pagination';
130
+
131
+ // Kit adds a typed surface-level extra without breaking cross-kit substitutability.
132
+ type MongokitPage<T> = OffsetPaginationResult<T, { warning?: string }>;
133
+
134
+ function render(page: MongokitPage<User>) {
135
+ if (page.warning) showBanner(page.warning);
136
+ return page.docs.map(userRow);
137
+ }
138
+ ```
139
+
140
+ Default `TExtra` is `Record<string, never>` — `OffsetPaginationResult<User>` behaves identically before and after; the generic is free to ignore.
141
+
142
+ ## Status
143
+
144
+ **v0.1.0 — initial release.**
145
+
146
+ Consumed by:
147
+ - `@classytic/mongokit` ≥ 3.10 — `Repository extends RepositoryBase`; hook engine, plugin-order validator, `HOOK_PRIORITY` sourced from repo-core. Mongokit's own `QueryParser` remains standalone (emits Mongo `$`-objects) but implements the same URL grammar by convention.
148
+ - `@classytic/sqlitekit` (in development) — `SqliteRepository extends RepositoryBase`; Filter IR compiled to Drizzle / raw SQL natively. Uses `parseUrl` directly for URL parsing.
149
+
150
+ See [INFRA.md](./INFRA.md) for the architectural principles, subpath map, build/tooling decisions, and the roadmap for pgkit / prismakit.
151
+
152
+ ## License
153
+
154
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,4 @@
1
+ import { CacheAdapter } from "./types.mjs";
2
+ import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
3
+ import { stableStringify } from "./stable-stringify.mjs";
4
+ export { type CacheAdapter, createMemoryCacheAdapter, stableStringify };
@@ -0,0 +1,3 @@
1
+ import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
2
+ import { stableStringify } from "./stable-stringify.mjs";
3
+ export { createMemoryCacheAdapter, stableStringify };
@@ -0,0 +1,7 @@
1
+ import { CacheAdapter } from "./types.mjs";
2
+
3
+ //#region src/cache/memory-adapter.d.ts
4
+ /** Minimal in-memory `Map`-backed adapter with per-key TTL + prefix invalidation. */
5
+ declare function createMemoryCacheAdapter(): CacheAdapter;
6
+ //#endregion
7
+ export { createMemoryCacheAdapter };
@@ -0,0 +1,37 @@
1
+ //#region src/cache/memory-adapter.ts
2
+ /** Minimal in-memory `Map`-backed adapter with per-key TTL + prefix invalidation. */
3
+ function createMemoryCacheAdapter() {
4
+ const store = /* @__PURE__ */ new Map();
5
+ const now = () => Date.now();
6
+ return {
7
+ get(key) {
8
+ const entry = store.get(key);
9
+ if (!entry) return void 0;
10
+ if (entry.expiresAt !== 0 && entry.expiresAt < now()) {
11
+ store.delete(key);
12
+ return;
13
+ }
14
+ return entry.value;
15
+ },
16
+ set(key, value, ttlSeconds = 60) {
17
+ const expiresAt = ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
18
+ store.set(key, {
19
+ value,
20
+ expiresAt
21
+ });
22
+ },
23
+ delete(key) {
24
+ store.delete(key);
25
+ },
26
+ clear(pattern) {
27
+ if (pattern === void 0) {
28
+ store.clear();
29
+ return;
30
+ }
31
+ const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
32
+ for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
33
+ }
34
+ };
35
+ }
36
+ //#endregion
37
+ export { createMemoryCacheAdapter };
@@ -0,0 +1,15 @@
1
+ //#region src/cache/stable-stringify.d.ts
2
+ /**
3
+ * Deterministic JSON stringify — equivalent values produce identical output.
4
+ *
5
+ * Used by kits that build cache keys from hook contexts so
6
+ * `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` hash to the same bucket. Arrays
7
+ * preserve order (order is part of array identity).
8
+ *
9
+ * Extracted from the mongokit / sqlitekit cache plugins into repo-core so
10
+ * every kit's cachePlugin uses the same keying rule — cross-kit caches
11
+ * remain bucket-compatible.
12
+ */
13
+ declare function stableStringify(value: unknown): string;
14
+ //#endregion
15
+ export { stableStringify };
@@ -0,0 +1,19 @@
1
+ //#region src/cache/stable-stringify.ts
2
+ /**
3
+ * Deterministic JSON stringify — equivalent values produce identical output.
4
+ *
5
+ * Used by kits that build cache keys from hook contexts so
6
+ * `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` hash to the same bucket. Arrays
7
+ * preserve order (order is part of array identity).
8
+ *
9
+ * Extracted from the mongokit / sqlitekit cache plugins into repo-core so
10
+ * every kit's cachePlugin uses the same keying rule — cross-kit caches
11
+ * remain bucket-compatible.
12
+ */
13
+ function stableStringify(value) {
14
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
15
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
16
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
17
+ }
18
+ //#endregion
19
+ export { stableStringify };
@@ -0,0 +1,59 @@
1
+ //#region src/cache/types.d.ts
2
+ /**
3
+ * Portable cache adapter contract.
4
+ *
5
+ * Kits (mongokit / sqlitekit / pgkit / prismakit) ship their own
6
+ * `cachePlugin` compositions; arc's `cache/QueryCache` composes on top of
7
+ * the same interface. One concrete Redis / KV / Memcached / in-memory
8
+ * implementation plugs into every consumer — no 95%-identical duplicates.
9
+ *
10
+ * ## What this contract covers
11
+ *
12
+ * Just the key-value transport layer — `get` / `set` / `del` / optional
13
+ * `clear`. Higher-level semantics (tag-based invalidation, SWR,
14
+ * stale-while-revalidate, hit-rate metrics, serialization) belong in the
15
+ * *consumer*, not the adapter. That keeps one adapter implementation
16
+ * re-usable across consumers with different cache strategies.
17
+ *
18
+ * ## TTL unit
19
+ *
20
+ * `ttlSeconds` — seconds, not milliseconds. Matches Redis (`SET key value
21
+ * EX seconds`), which is the dominant backend. `0` or `undefined` means no
22
+ * expiry; adapters are free to apply a default.
23
+ *
24
+ * ## Sync-or-async return types
25
+ *
26
+ * Both accepted. An in-memory `Map` adapter returns values synchronously; a
27
+ * Redis adapter returns Promises. Consumers `await` either way — awaiting
28
+ * a non-Promise value is a no-op at runtime.
29
+ */
30
+ interface CacheAdapter {
31
+ /** Get a value by key. Returns `undefined` when not found or expired. */
32
+ get(key: string): Promise<unknown | undefined> | unknown | undefined;
33
+ /** Store a value with optional TTL (seconds). */
34
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void> | void;
35
+ /**
36
+ * Delete a single key. No-op when the key doesn't exist.
37
+ *
38
+ * Named `delete` (not `del`) to match the rest of the ecosystem:
39
+ * `MinimalRepo.delete(id)` in this same package, JavaScript's native
40
+ * `Map.delete` / `Set.delete`, arc's `RepositoryLike.delete`, and every
41
+ * higher-level cache library (Keyv, etc.). Redis clients keep their
42
+ * own `.del()` method — adapter implementations translate.
43
+ */
44
+ delete(key: string): Promise<void> | void;
45
+ /**
46
+ * Invalidate keys matching a glob pattern (typically `prefix:*`), or
47
+ * every key when `pattern` is omitted.
48
+ *
49
+ * Optional — simpler adapters that can't enumerate keys (some KV stores)
50
+ * may omit this and rely on TTL for eventual consistency. Consumers that
51
+ * need strict invalidation must check for its presence: `adapter.clear?.(pattern)`.
52
+ *
53
+ * Named `clear` (matching mongokit + arc) rather than `delByPattern` so
54
+ * one interface flows across every layer.
55
+ */
56
+ clear?(pattern?: string): Promise<void> | void;
57
+ }
58
+ //#endregion
59
+ export { CacheAdapter };
@@ -0,0 +1,2 @@
1
+ import { RepositoryContext } from "./types.mjs";
2
+ export { type RepositoryContext };
File without changes
@@ -0,0 +1,24 @@
1
+ import { Filter } from "../filter/types.mjs";
2
+
3
+ //#region src/context/types.d.ts
4
+ /** The canonical lifecycle-hook context. */
5
+ interface RepositoryContext {
6
+ /** Operation name (`'create'`, `'getAll'`, `'update'`, ...). */
7
+ operation: string;
8
+ /** Repository / model identifier. Set by `RepositoryBase` from its constructor. */
9
+ model: string;
10
+ /** Primary key for id-bearing ops. */
11
+ id?: string | number | unknown;
12
+ /** Raw filter — Filter IR or plain record. Used by most read/write ops. */
13
+ query?: Filter | Record<string, unknown>;
14
+ /** Paginated-list filter sub-bag (distinct from `query` — see docstring). */
15
+ filters?: Filter | Record<string, unknown>;
16
+ /** Single-doc create/update payload. */
17
+ data?: Record<string, unknown>;
18
+ /** Multi-doc create payload. */
19
+ dataArray?: Record<string, unknown>[];
20
+ /** Arbitrary plugin-owned fields (cache markers, soft-delete flags, ...). */
21
+ [key: string]: unknown;
22
+ }
23
+ //#endregion
24
+ export { RepositoryContext };
@@ -0,0 +1,19 @@
1
+ import { HttpError } from "./types.mjs";
2
+
3
+ //#region src/errors/create-error.d.ts
4
+ /**
5
+ * Create an error with an HTTP status code attached.
6
+ *
7
+ * Pure helper. No driver knowledge. Used by every kit at its error boundary:
8
+ *
9
+ * ```ts
10
+ * throw createError(404, 'Document not found');
11
+ * throw createError(400, 'Invalid input');
12
+ * throw createError(409, 'Conflict');
13
+ * ```
14
+ */
15
+ declare function createError(status: number, message: string): HttpError;
16
+ /** Runtime predicate — true when `value` already carries an `HttpError` shape. */
17
+ declare function isHttpError(value: unknown): value is HttpError;
18
+ //#endregion
19
+ export { createError, isHttpError };
@@ -0,0 +1,23 @@
1
+ //#region src/errors/create-error.ts
2
+ /**
3
+ * Create an error with an HTTP status code attached.
4
+ *
5
+ * Pure helper. No driver knowledge. Used by every kit at its error boundary:
6
+ *
7
+ * ```ts
8
+ * throw createError(404, 'Document not found');
9
+ * throw createError(400, 'Invalid input');
10
+ * throw createError(409, 'Conflict');
11
+ * ```
12
+ */
13
+ function createError(status, message) {
14
+ return Object.assign(new Error(message), { status });
15
+ }
16
+ /** Runtime predicate — true when `value` already carries an `HttpError` shape. */
17
+ function isHttpError(value) {
18
+ if (!(value instanceof Error)) return false;
19
+ const status = value.status;
20
+ return typeof status === "number" && Number.isFinite(status);
21
+ }
22
+ //#endregion
23
+ export { createError, isHttpError };
@@ -0,0 +1,38 @@
1
+ import { DuplicateKeyMeta, HttpError } from "./types.mjs";
2
+
3
+ //#region src/errors/duplicate-key.d.ts
4
+ /** Predicate shape kits implement and repositories expose as `isDuplicateKeyError`. */
5
+ type IsDuplicateKeyErrorFn = (err: unknown) => boolean;
6
+ /**
7
+ * Conservative fallback predicate. Used by arc ONLY when the repository
8
+ * doesn't expose `isDuplicateKeyError` — preserves back-compat with
9
+ * mongokit ≤3.8 which didn't export the predicate. Matches ONLY MongoDB's
10
+ * narrow signals so it never swallows a transactional retry error.
11
+ *
12
+ * Non-Mongo kits MUST implement their own `isDuplicateKeyError` — the
13
+ * fallback returns `false` for their native signals (P2002, 23505, etc.).
14
+ */
15
+ declare const conservativeMongoIsDuplicateKey: IsDuplicateKeyErrorFn;
16
+ /** Options for `toDuplicateKeyHttpError`. */
17
+ interface ToDuplicateKeyHttpErrorOptions {
18
+ /**
19
+ * Include offending values inline in the error message and under
20
+ * `error.duplicate.values`. Default `false` — values can be PII and
21
+ * end up in logs/crash reports. Enable in dev or trusted internal tools.
22
+ */
23
+ exposeValues?: boolean;
24
+ }
25
+ /**
26
+ * Build a 409 `HttpError` from already-extracted duplicate-key metadata.
27
+ *
28
+ * Kits call their driver-specific extractor (`extractMongoE11000`,
29
+ * `extractPrismaP2002`, ...) and pass the resulting `{ fields, values }`
30
+ * bundle here. Repo-core stays driver-free — it only knows the shape of
31
+ * the canonical 409 error.
32
+ *
33
+ * PII-safe by default: the message names fields only. Values are attached
34
+ * only when `exposeValues: true`.
35
+ */
36
+ declare function toDuplicateKeyHttpError(meta: DuplicateKeyMeta, options?: ToDuplicateKeyHttpErrorOptions): HttpError;
37
+ //#endregion
38
+ export { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError };
@@ -0,0 +1,57 @@
1
+ import { createError } from "./create-error.mjs";
2
+ //#region src/errors/duplicate-key.ts
3
+ /**
4
+ * Duplicate-key detection — driver-agnostic contract.
5
+ *
6
+ * Every backend signals unique-constraint violations differently:
7
+ *
8
+ * | Backend | Signal |
9
+ * |------------|-------------------------------------------------------|
10
+ * | MongoDB | `err.code === 11000` / `codeName === 'DuplicateKey'` |
11
+ * | Prisma | `err.code === 'P2002'` |
12
+ * | Postgres | `err.code === '23505'` |
13
+ * | SQLite | message contains `'UNIQUE constraint failed'` |
14
+ *
15
+ * Classification belongs in the kit that knows its driver. Arc's
16
+ * idempotency / outbox adapters depend only on the boolean outcome of
17
+ * `repository.isDuplicateKeyError(err)`, so this module defines the
18
+ * shared contract — the `IsDuplicateKeyErrorFn` type and a safe default
19
+ * fallback — and leaves the concrete predicate to each kit.
20
+ */
21
+ /**
22
+ * Conservative fallback predicate. Used by arc ONLY when the repository
23
+ * doesn't expose `isDuplicateKeyError` — preserves back-compat with
24
+ * mongokit ≤3.8 which didn't export the predicate. Matches ONLY MongoDB's
25
+ * narrow signals so it never swallows a transactional retry error.
26
+ *
27
+ * Non-Mongo kits MUST implement their own `isDuplicateKeyError` — the
28
+ * fallback returns `false` for their native signals (P2002, 23505, etc.).
29
+ */
30
+ const conservativeMongoIsDuplicateKey = (err) => {
31
+ if (!err || typeof err !== "object") return false;
32
+ const e = err;
33
+ return e.code === 11e3 || e.codeName === "DuplicateKey";
34
+ };
35
+ /**
36
+ * Build a 409 `HttpError` from already-extracted duplicate-key metadata.
37
+ *
38
+ * Kits call their driver-specific extractor (`extractMongoE11000`,
39
+ * `extractPrismaP2002`, ...) and pass the resulting `{ fields, values }`
40
+ * bundle here. Repo-core stays driver-free — it only knows the shape of
41
+ * the canonical 409 error.
42
+ *
43
+ * PII-safe by default: the message names fields only. Values are attached
44
+ * only when `exposeValues: true`.
45
+ */
46
+ function toDuplicateKeyHttpError(meta, options = {}) {
47
+ const exposed = options.exposeValues === true;
48
+ const valuesString = exposed && meta.values ? Object.entries(meta.values).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
49
+ const httpError = createError(409, meta.fields.length ? `Duplicate value for ${meta.fields.join(", ")}${valuesString ? ` (${valuesString})` : ""}` : "Duplicate key error");
50
+ httpError.duplicate = {
51
+ fields: meta.fields,
52
+ ...exposed && meta.values ? { values: { ...meta.values } } : {}
53
+ };
54
+ return httpError;
55
+ }
56
+ //#endregion
57
+ export { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError };
@@ -0,0 +1,4 @@
1
+ import { DuplicateKeyMeta, HttpError, ValidationErrorMeta } from "./types.mjs";
2
+ import { createError, isHttpError } from "./create-error.mjs";
3
+ import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
4
+ export { type DuplicateKeyMeta, type HttpError, type IsDuplicateKeyErrorFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, conservativeMongoIsDuplicateKey, createError, isHttpError, toDuplicateKeyHttpError };
@@ -0,0 +1,3 @@
1
+ import { createError, isHttpError } from "./create-error.mjs";
2
+ import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
3
+ export { conservativeMongoIsDuplicateKey, createError, isHttpError, toDuplicateKeyHttpError };
@@ -0,0 +1,37 @@
1
+ //#region src/errors/types.d.ts
2
+ /**
3
+ * HTTP-shaped error contract used across every driver kit.
4
+ *
5
+ * An `HttpError` is a plain `Error` with a `status` field and optional
6
+ * structured fields for duplicate-key and validation conflicts. Kits
7
+ * classify their driver-specific errors into this shape at the boundary,
8
+ * so the framework layer (arc) never needs to know whether the error
9
+ * originated from MongoDB `E11000`, Postgres `23505`, or Prisma `P2002`.
10
+ */
11
+ /** Structured metadata for duplicate-key (unique-constraint) errors. */
12
+ interface DuplicateKeyMeta {
13
+ /** Offending field names. Always safe to log. */
14
+ fields: string[];
15
+ /**
16
+ * Offending values. ONLY populated when the kit's duplicate-key parser
17
+ * was explicitly called with `{ exposeValues: true }`. Values may contain
18
+ * PII (emails, tokens, phone numbers) — never log unconditionally.
19
+ */
20
+ values?: Record<string, unknown>;
21
+ }
22
+ /** Structured metadata for validation errors. */
23
+ interface ValidationErrorMeta {
24
+ validator: string;
25
+ error: string;
26
+ }
27
+ /** HTTP-shaped error — the envelope every repository error resolves to. */
28
+ interface HttpError extends Error {
29
+ /** HTTP status code (400, 404, 409, 500, ...). */
30
+ status: number;
31
+ /** Structured validation failures when `status` is 400. */
32
+ validationErrors?: ValidationErrorMeta[];
33
+ /** Structured duplicate-key metadata when `status` is 409. */
34
+ duplicate?: DuplicateKeyMeta;
35
+ }
36
+ //#endregion
37
+ export { DuplicateKeyMeta, HttpError, ValidationErrorMeta };