@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.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/cache/index.d.mts +4 -0
- package/dist/cache/index.mjs +3 -0
- package/dist/cache/memory-adapter.d.mts +7 -0
- package/dist/cache/memory-adapter.mjs +37 -0
- package/dist/cache/stable-stringify.d.mts +15 -0
- package/dist/cache/stable-stringify.mjs +19 -0
- package/dist/cache/types.d.mts +59 -0
- package/dist/context/index.d.mts +2 -0
- package/dist/context/index.mjs +0 -0
- package/dist/context/types.d.mts +24 -0
- package/dist/errors/create-error.d.mts +19 -0
- package/dist/errors/create-error.mjs +23 -0
- package/dist/errors/duplicate-key.d.mts +38 -0
- package/dist/errors/duplicate-key.mjs +57 -0
- package/dist/errors/index.d.mts +4 -0
- package/dist/errors/index.mjs +3 -0
- package/dist/errors/types.d.mts +37 -0
- package/dist/filter/builders.d.mts +60 -0
- package/dist/filter/builders.mjs +172 -0
- package/dist/filter/guard.d.mts +13 -0
- package/dist/filter/guard.mjs +34 -0
- package/dist/filter/index.d.mts +7 -0
- package/dist/filter/index.mjs +6 -0
- package/dist/filter/match.d.mts +12 -0
- package/dist/filter/match.mjs +91 -0
- package/dist/filter/scope.d.mts +31 -0
- package/dist/filter/scope.mjs +54 -0
- package/dist/filter/types.d.mts +143 -0
- package/dist/filter/walk.d.mts +24 -0
- package/dist/filter/walk.mjs +77 -0
- package/dist/hooks/engine.d.mts +48 -0
- package/dist/hooks/engine.mjs +101 -0
- package/dist/hooks/events.d.mts +95 -0
- package/dist/hooks/events.mjs +93 -0
- package/dist/hooks/index.d.mts +5 -0
- package/dist/hooks/index.mjs +4 -0
- package/dist/hooks/priority.d.mts +23 -0
- package/dist/hooks/priority.mjs +21 -0
- package/dist/hooks/types.d.mts +37 -0
- package/dist/lookup/index.d.mts +2 -0
- package/dist/lookup/index.mjs +0 -0
- package/dist/lookup/types.d.mts +170 -0
- package/dist/operations/index.d.mts +3 -0
- package/dist/operations/index.mjs +2 -0
- package/dist/operations/registry.d.mts +41 -0
- package/dist/operations/registry.mjs +140 -0
- package/dist/operations/types.d.mts +49 -0
- package/dist/pagination/cursor.d.mts +44 -0
- package/dist/pagination/cursor.mjs +150 -0
- package/dist/pagination/index.d.mts +5 -0
- package/dist/pagination/index.mjs +4 -0
- package/dist/pagination/keyset.d.mts +25 -0
- package/dist/pagination/keyset.mjs +61 -0
- package/dist/pagination/offset.d.mts +26 -0
- package/dist/pagination/offset.mjs +47 -0
- package/dist/pagination/types.d.mts +136 -0
- package/dist/query-parser/coerce.d.mts +16 -0
- package/dist/query-parser/coerce.mjs +73 -0
- package/dist/query-parser/index.d.mts +4 -0
- package/dist/query-parser/index.mjs +3 -0
- package/dist/query-parser/parse-url.d.mts +7 -0
- package/dist/query-parser/parse-url.mjs +224 -0
- package/dist/query-parser/types.d.mts +104 -0
- package/dist/repository/base.d.mts +90 -0
- package/dist/repository/base.mjs +111 -0
- package/dist/repository/index.d.mts +5 -0
- package/dist/repository/index.mjs +3 -0
- package/dist/repository/plugin-types.d.mts +27 -0
- package/dist/repository/plugin-types.mjs +45 -0
- package/dist/repository/types.d.mts +470 -0
- package/dist/schema/field-rules.d.mts +62 -0
- package/dist/schema/field-rules.mjs +110 -0
- package/dist/schema/index.d.mts +3 -0
- package/dist/schema/index.mjs +2 -0
- package/dist/schema/types.d.mts +138 -0
- package/dist/testing/conformance.d.mts +6 -0
- package/dist/testing/conformance.mjs +481 -0
- package/dist/testing/index.d.mts +3 -0
- package/dist/testing/index.mjs +2 -0
- package/dist/testing/types.d.mts +113 -0
- 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,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 };
|
|
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,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 };
|