@classytic/repo-core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ All notable changes to `@classytic/repo-core` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.1] - 2026-07-04
8
+
9
+ ### Changed — `TenantConfig` optionals widened to `T | undefined` (P10)
10
+
11
+ Every optional prop on `TenantConfig` (`strategy`, `enabled`, `tenantField`,
12
+ `fieldType`, `ref`, `contextKey`, `required`, `resolve`) is now typed
13
+ `T | undefined`, so downstream packages compiling with
14
+ `exactOptionalPropertyTypes: true` can extend it without redeclaring props
15
+ (unblocks `@classytic/ledger`'s `MultiTenantConfig.required` exception).
16
+
17
+ `resolveTenantConfig` strips explicit-`undefined` keys before spreading
18
+ over `DEFAULT_TENANT_CONFIG`, so `{ required: maybeUndefined }` can never
19
+ clobber a default with `undefined` — previously the un-widened type made
20
+ that unrepresentable; now it's handled at runtime and pinned by tests.
21
+ `DEFAULT_TENANT_CONFIG`'s annotation moved from `Required<Pick<...>>` to an
22
+ `Exclude<..., undefined>` mapping (`-?` does not strip an explicit
23
+ undefined union member). No runtime behavior change for existing callers.
24
+
25
+ ## [0.6.0] - 2026-06-11
26
+
27
+ Standardization release. Coordinated with mongokit 3.16 + sqlitekit 0.6.
28
+
29
+ ### Added
30
+
31
+ - **`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.
32
+ - **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`.
33
+ - **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`).
34
+ - **Change feed contract** — optional `StandardRepo.watch?()` returning `AsyncIterable<ChangeEvent<TDoc>>`; gated by `capabilities.changeStreams`.
35
+ - **Resilience** — `QueryOptions.signal` + `QueryOptions.retryPolicy`; shared `withRetry()` / `throwIfAborted()` helpers.
36
+ - **`recordToFilter()`** on `/filter` — promoted from per-kit copies.
37
+ - `STANDARD_REPO_OPTION_KEYS` gains `'traceId'`.
38
+
39
+ ### Changed
40
+
41
+ - `PurgeRetryPolicy` → **`RetryPolicy`** (rename, identical shape; was declared twice — now once). Clean break, no alias.
42
+ - `AggregateOpsSupport` moved to `repository/capabilities.ts` (still re-exported from `/testing`).
43
+
7
44
  ## [0.5.0] - 2026-05-17
8
45
 
9
46
  ### Added — compliance-grade tenant cleanup primitive
@@ -28,7 +65,7 @@ on top.
28
65
  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.
29
66
 
30
67
  - **`runChunkedPurge(strategy, options, port)`** — pure orchestrator (130 lines, no I/O).
31
- - **`PurgePort`** interface — the driving port. Each kit implements two closures: `selectChunkIds(limit)` + `applyStrategy(ids, strategy)`.
68
+ - **`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).
32
69
  - **`WritingPurgeStrategy`** — strategy union with `skip` excluded (orchestrator handles `skip` before the port is consulted, so ports only see `hard` / `soft` / `anonymize`).
33
70
 
34
71
  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.
package/README.md CHANGED
@@ -62,6 +62,19 @@ import { CORE_OP_REGISTRY, describe } from '@classytic/repo-core/operations';
62
62
 
63
63
  // Repository hook context type (for plugin authors).
64
64
  import type { RepositoryContext } from '@classytic/repo-core/context';
65
+
66
+ // Capabilities + resilience (0.6.0) — runtime feature detection, the unified
67
+ // retry/abort primitives, and the change-feed contract types.
68
+ import { withRetry, throwIfAborted } from '@classytic/repo-core/repository';
69
+ import type { RepoCapabilities, RetryPolicy, ChangeEvent, WatchOptions } from '@classytic/repo-core/repository';
70
+
71
+ // Domain events (0.6.0) — pass `events: { transport }` at construction and every
72
+ // mutating op publishes `<resource>.<verb>` through any arc-compatible transport.
73
+ import type { RepositoryEventPublisher, DomainEvent } from '@classytic/repo-core/events';
74
+
75
+ // Standard Schema validation (0.6.0) — `schema` / `updateSchema` construction
76
+ // options accept any Zod / Valibot / ArkType / Effect schema.
77
+ import { validateStandardSchema, type StandardSchemaV1 } from '@classytic/repo-core/schema';
65
78
  ```
66
79
 
67
80
  **There is no `.` / root entry.** Import from the exact subpath — that's the contract that keeps tree-shaking honest.
@@ -143,6 +156,51 @@ export function stampOrgId(orgId: string): Plugin {
143
156
 
144
157
  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.
145
158
 
159
+ ## Capabilities — feature detection, not runtime surprises
160
+
161
+ Every kit declares `readonly capabilities: RepoCapabilities` (required on
162
+ `StandardRepo` since 0.6.0). Kit-portable hosts branch once at boot:
163
+
164
+ ```ts
165
+ if (!repo.capabilities.arrayOperators) {
166
+ // SQL kit without JSON array rewrites — model tags as a join table
167
+ }
168
+ if (repo.capabilities.aggregateOps?.percentile) {
169
+ dashboard.enableLatencyPercentiles();
170
+ }
171
+ ```
172
+
173
+ The conformance suite's `ConformanceFeatures` is an alias of the same type —
174
+ what a kit declares at runtime is exactly what the cross-kit suite verifies.
175
+
176
+ ## Config-driven validation + events (0.6.0)
177
+
178
+ `RepositoryBaseOptions` follows media-kit's config-driven activation: pass the
179
+ slot and the feature lights up; omit it and the wiring is inert.
180
+
181
+ ```ts
182
+ import { z } from 'zod';
183
+
184
+ const repo = createRepository(UserModel, {
185
+ // Any Standard Schema validator — Zod, Valibot, ArkType, Effect.
186
+ schema: z.object({ name: z.string().min(1), email: z.string().email() }),
187
+ updateSchema: z.object({ name: z.string().min(1) }).partial(),
188
+
189
+ // Any arc / primitives-compatible EventTransport. Every mutating op then
190
+ // publishes `user.created` / `user.updated` / `user.deleted` / ...
191
+ events: { transport: redisTransport, source: 'commerce' },
192
+ });
193
+ ```
194
+
195
+ Validation runs at `HOOK_PRIORITY.VALIDATION` (150) — after policy plugins
196
+ (tenant-stamped fields are present), before cache. Event publishing never
197
+ fails the operation; transport failures route to the `error:events` hook.
198
+
199
+ > **Warning — arc hosts:** these are the same event names `@classytic/arc`'s
200
+ > `eventStrategy: 'auto'` emits. Wiring BOTH the repo-level bridge and arc
201
+ > auto events for one resource double-publishes silently (arc's dual-publish
202
+ > dev-warn cannot see the repo layer). Pick one layer per resource.
203
+
146
204
  ## Typed result extras
147
205
 
148
206
  ```ts
@@ -161,11 +219,15 @@ Default `TExtra` is `Record<string, never>` — `OffsetPaginationResult<User>` b
161
219
 
162
220
  ## Status
163
221
 
164
- **v0.3.0 — canonical contracts release.** Pagination types + wire envelope, tenant config, error contracts, and the `SchemaGenerator<TModel>` interface relocated from primitives / mongokit / arc to single sources of truth here.
222
+ **v0.6.0 — standardization release.** Required `RepoCapabilities` feature
223
+ detection (unified with `ConformanceFeatures`), Standard Schema validation
224
+ slot, domain-event emission (`/events`), `watch()` change-feed contract,
225
+ unified `RetryPolicy` + `signal` cancellation, and `recordToFilter`
226
+ promoted from per-kit copies.
165
227
 
166
228
  Consumed by:
167
- - `@classytic/mongokit` ≥ 3.12 — `Repository extends RepositoryBase`; hook engine, plugin-order validator, `HOOK_PRIORITY` sourced from repo-core. Pagination + `HttpError` types now flow from repo-core (mongokit's local copies dropped). `MultiTenantOptions extends Pick<TenantConfig, ...>`. `buildCrudSchemasFromModel` ships a compile-time `SchemaGenerator<TModel>` conformance assertion. Mongokit's own `QueryParser` remains standalone.
168
- - `@classytic/sqlitekit` ≥ 0.2 — `SqliteRepository extends RepositoryBase`; Filter IR compiled to Drizzle / raw SQL natively. `MultiTenantOptions extends Pick<TenantConfig, ...>`. `buildCrudSchemasFromTable` ships the same `SchemaGenerator` conformance assertion.
229
+ - `@classytic/mongokit` ≥ 3.16 — `Repository extends RepositoryBase`; declares `MONGOKIT_CAPABILITIES`; implements `watch()` via change streams; hook engine, plugin-order validator, `HOOK_PRIORITY`, pagination + `HttpError` types all flow from repo-core. Mongokit's own `QueryParser` remains standalone.
230
+ - `@classytic/sqlitekit` ≥ 0.6 — `SqliteRepository extends RepositoryBase`; declares `SQLITEKIT_CAPABILITIES`; Filter IR compiled to Drizzle / raw SQL natively; `recordToFilter` consumed from here.
169
231
  - `@classytic/arc` ≥ 2.12 — adapters typed against `SchemaGenerator<TModel>`; `ArcError implements HttpError`; pagination wire envelope (`method` discriminant) emitted via `toCanonicalList()` with `reply.sendList()`.
170
232
 
171
233
  See [INFRA.md](./INFRA.md) for the architectural principles, subpath map, build/tooling decisions, and the roadmap for pgkit / prismakit.
@@ -0,0 +1,22 @@
1
+ import { EventMeta, RepositoryEventPublisher } from "./types.mjs";
2
+ import { RepositoryBase } from "../repository/base.mjs";
3
+
4
+ //#region src/events/emit.d.ts
5
+ /** Construction-time event wiring. */
6
+ interface RepositoryEventsOptions {
7
+ /** Any arc / primitives-compatible transport. Only `publish` is consumed. */
8
+ transport: RepositoryEventPublisher;
9
+ /**
10
+ * Event-name prefix. Defaults to the repository's model name lowercased
11
+ * (`User` model → `user.created`).
12
+ */
13
+ resource?: string;
14
+ /** `meta.source` — originating service/package (`'commerce'`, `'billing'`). */
15
+ source?: string;
16
+ /** Static meta merged into every event (host-controlled overrides win). */
17
+ meta?: Partial<EventMeta>;
18
+ }
19
+ /** Install the after-hooks. Called by `RepositoryBase`; not host-facing. */
20
+ declare function registerRepositoryEvents(repo: RepositoryBase, options: RepositoryEventsOptions): void;
21
+ //#endregion
22
+ export { RepositoryEventsOptions, registerRepositoryEvents };
@@ -0,0 +1,105 @@
1
+ import { HOOK_PRIORITY } from "../hooks/priority.mjs";
2
+ //#region src/events/emit.ts
3
+ const CREATED_OPS = ["create"];
4
+ const UPDATED_OPS = [
5
+ "update",
6
+ "findOneAndUpdate",
7
+ "claim",
8
+ "claimVersion",
9
+ "replace",
10
+ "upsert"
11
+ ];
12
+ function eventId() {
13
+ const c = globalThis.crypto;
14
+ if (c?.randomUUID) return c.randomUUID();
15
+ return `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
16
+ }
17
+ function asRecord(value) {
18
+ return value !== null && typeof value === "object" ? value : void 0;
19
+ }
20
+ /** Pull actor/tenant/correlation meta off the hook context (and its options bag). */
21
+ function contextMeta(context) {
22
+ const options = asRecord(context["options"]) ?? {};
23
+ const pick = (key) => options[key] ?? context[key];
24
+ const meta = {};
25
+ const userId = pick("userId");
26
+ if (typeof userId === "string") meta.userId = userId;
27
+ const organizationId = pick("organizationId");
28
+ if (typeof organizationId === "string") meta.organizationId = organizationId;
29
+ const requestId = pick("requestId");
30
+ if (typeof requestId === "string") meta.correlationId = requestId;
31
+ return meta;
32
+ }
33
+ function resourceId(result, context) {
34
+ const doc = asRecord(result);
35
+ const raw = doc?.["_id"] ?? doc?.["id"] ?? context.id;
36
+ return raw === void 0 || raw === null ? void 0 : String(raw);
37
+ }
38
+ /** Install the after-hooks. Called by `RepositoryBase`; not host-facing. */
39
+ function registerRepositoryEvents(repo, options) {
40
+ const resource = options.resource ?? repo.modelName.toLowerCase();
41
+ const transport = options.transport;
42
+ const buildEvent = (verb, payload, context, id) => ({
43
+ type: `${resource}.${verb}`,
44
+ payload,
45
+ meta: {
46
+ id: eventId(),
47
+ timestamp: /* @__PURE__ */ new Date(),
48
+ resource,
49
+ ...id !== void 0 ? {
50
+ resourceId: id,
51
+ partitionKey: id
52
+ } : {},
53
+ ...options.source !== void 0 ? { source: options.source } : {},
54
+ ...contextMeta(context),
55
+ ...options.meta
56
+ }
57
+ });
58
+ const publish = async (event) => {
59
+ try {
60
+ await transport.publish(event);
61
+ } catch (err) {
62
+ repo.emit("error:events", {
63
+ event,
64
+ error: err
65
+ });
66
+ }
67
+ };
68
+ const onResult = (verb) => async ({ context, result }) => {
69
+ if (result === null || result === void 0) return;
70
+ await publish(buildEvent(verb, result, context, resourceId(result, context)));
71
+ };
72
+ const observe = { priority: HOOK_PRIORITY.OBSERVABILITY };
73
+ for (const op of CREATED_OPS) repo.on(`after:${op}`, onResult("created"), observe);
74
+ for (const op of UPDATED_OPS) repo.on(`after:${op}`, onResult("updated"), observe);
75
+ repo.on(`after:restore`, onResult("restored"), observe);
76
+ repo.on("after:delete", async ({ context, result }) => {
77
+ if (result === null || result === void 0) return;
78
+ const id = context.id === void 0 || context.id === null ? void 0 : String(context.id);
79
+ await publish(buildEvent("deleted", id !== void 0 ? { id } : result, context, id));
80
+ }, observe);
81
+ repo.on("after:getOrCreate", async ({ context, result }) => {
82
+ const envelope = asRecord(result);
83
+ if (!envelope || envelope["created"] !== true) return;
84
+ const doc = envelope["doc"];
85
+ await publish(buildEvent("created", doc, context, resourceId(doc, context)));
86
+ }, observe);
87
+ repo.on("after:createMany", async ({ context, result }) => {
88
+ if (!Array.isArray(result) || result.length === 0) return;
89
+ const events = result.map((doc) => buildEvent("created", doc, context, resourceId(doc, context)));
90
+ try {
91
+ if (transport.publishMany) await transport.publishMany(events);
92
+ else for (const event of events) await transport.publish(event);
93
+ } catch (err) {
94
+ repo.emit("error:events", {
95
+ event: events[0],
96
+ error: err
97
+ });
98
+ }
99
+ }, observe);
100
+ for (const [op, verb] of [["updateMany", "updatedMany"], ["deleteMany", "deletedMany"]]) repo.on(`after:${op}`, async ({ context, result }) => {
101
+ await publish(buildEvent(verb, result, context, void 0));
102
+ }, observe);
103
+ }
104
+ //#endregion
105
+ export { registerRepositoryEvents };
@@ -0,0 +1,3 @@
1
+ import { DomainEvent, EventMeta, PublishManyResult, RepositoryEventPublisher } from "./types.mjs";
2
+ import { RepositoryEventsOptions, registerRepositoryEvents } from "./emit.mjs";
3
+ export { type DomainEvent, type EventMeta, type PublishManyResult, type RepositoryEventPublisher, type RepositoryEventsOptions, registerRepositoryEvents };
@@ -0,0 +1,2 @@
1
+ import { registerRepositoryEvents } from "./emit.mjs";
2
+ export { registerRepositoryEvents };
@@ -0,0 +1,73 @@
1
+ //#region src/events/types.d.ts
2
+ /**
3
+ * Domain-event types — structural mirror of the org-wide event contract.
4
+ *
5
+ * These shapes are structurally compatible with `@classytic/primitives/events`
6
+ * (which itself mirrors `@classytic/arc`'s `EventTransport` verbatim). Repo-core
7
+ * declares only the subset it consumes — `publish` / `publishMany` — so ANY
8
+ * arc transport (memory, Redis, Kafka), primitives' helpers, or media-kit's
9
+ * in-process bus drops into `RepositoryBaseOptions.events.transport` without
10
+ * adapters, and repo-core stays zero-dependency.
11
+ *
12
+ * Width subtyping does the work: a full `EventTransport` (with `subscribe`,
13
+ * `deadLetter`, `close`) satisfies this narrower parameter type automatically.
14
+ */
15
+ /**
16
+ * Event metadata. Field semantics mirror arc v2.9 / primitives — see
17
+ * `@classytic/primitives/events` for the exhaustive docs. Repo-core
18
+ * populates `id`, `timestamp`, `resource`, `resourceId`, `source`, and —
19
+ * when present on the call's options bag — `userId`, `organizationId`,
20
+ * `correlationId` (from `requestId`).
21
+ */
22
+ interface EventMeta {
23
+ /** Unique event identifier — UUID v4. */
24
+ id: string;
25
+ /** Emit timestamp. */
26
+ timestamp: Date;
27
+ /** Schema version for this event type. Default: `1`. */
28
+ schemaVersion?: number;
29
+ /** Correlation ID — stable across a causal chain (repo-core forwards `requestId`). */
30
+ correlationId?: string;
31
+ /** Causation ID — `meta.id` of the direct parent event. */
32
+ causationId?: string;
33
+ /** Partition key hint for ordered transports. Defaults to `resourceId`. */
34
+ partitionKey?: string;
35
+ /** Source resource name — the repository's model/table name. */
36
+ resource?: string;
37
+ /** Resource identifier — the document's primary key. */
38
+ resourceId?: string;
39
+ /** User who triggered the event. */
40
+ userId?: string;
41
+ /** Organization / tenant scope. */
42
+ organizationId?: string;
43
+ /** Originating service or package. */
44
+ source?: string;
45
+ /** Idempotency key — stable per logical operation. */
46
+ idempotencyKey?: string;
47
+ /** DDD aggregate marker. */
48
+ aggregate?: {
49
+ type: string;
50
+ id: string;
51
+ };
52
+ }
53
+ /** A domain event — `type` is dotted (`user.created`, `order.updatedMany`). */
54
+ interface DomainEvent<T = unknown> {
55
+ type: string;
56
+ payload: T;
57
+ meta: EventMeta;
58
+ }
59
+ /** Per-event publish outcome keyed by `meta.id`. `null` = success. */
60
+ type PublishManyResult = ReadonlyMap<string, Error | null>;
61
+ /**
62
+ * The transport surface repo-core consumes. Structurally satisfied by any
63
+ * arc / primitives `EventTransport`. Repositories only PUBLISH — subscribing
64
+ * is host territory, on whichever full transport the host wired.
65
+ */
66
+ interface RepositoryEventPublisher {
67
+ readonly name: string;
68
+ publish(event: DomainEvent): Promise<void>;
69
+ /** Batch publish (optional). Used for `createMany` when available. */
70
+ publishMany?(events: readonly DomainEvent[]): Promise<PublishManyResult>;
71
+ }
72
+ //#endregion
73
+ export { DomainEvent, EventMeta, PublishManyResult, RepositoryEventPublisher };
@@ -0,0 +1,13 @@
1
+ import { Filter } from "./types.mjs";
2
+
3
+ //#region src/filter/from-record.d.ts
4
+ /**
5
+ * Convert a plain record (Mongo-style query object) into a Filter IR
6
+ * tree. Multiple top-level keys AND together. Empty record → `TRUE`.
7
+ *
8
+ * Already-IR inputs (anything with a `.op` field) pass through
9
+ * unchanged so callers can mix-and-match without branching.
10
+ */
11
+ declare function recordToFilter(input: Filter | Record<string, unknown>): Filter;
12
+ //#endregion
13
+ export { recordToFilter };
@@ -0,0 +1,135 @@
1
+ import { TRUE, and, contains, endsWith, eq, gt, gte, in_, isNotNull, isNull, like, lt, lte, ne, nin, startsWith } from "./builders.mjs";
2
+ //#region src/filter/from-record.ts
3
+ /**
4
+ * Plain-record → Filter IR conversion — the canonical normalizer every
5
+ * kit shares (promoted from per-kit copies in repo-core 0.6.0).
6
+ *
7
+ * The portable Filter IR is the canonical input every kit's compile path
8
+ * expects, but several surfaces accept a record-shape shorthand for
9
+ * ergonomics:
10
+ *
11
+ * - `LookupSpec.where` (declared as `Filter | Record<string, unknown>`)
12
+ * - `AggRequest.filter` and `AggRequest.having`
13
+ * - Top-level filter args on `getAll`, `getOne`, etc.
14
+ *
15
+ * Mongo-style record syntax maps cleanly to IR leaves:
16
+ *
17
+ * `{ status: 'active' }` → `eq('status', 'active')`
18
+ * `{ price: { gte: 100, lt: 1000 } }` → `and(gte('price', 100), lt('price', 1000))`
19
+ * `{ tags: { in: ['a', 'b'] } }` → `in_('tags', ['a', 'b'])`
20
+ * `{ deletedAt: null }` → `isNull('deletedAt')`
21
+ * `{ active: true, role: 'admin' }` → `and(eq('active', true), eq('role', 'admin'))`
22
+ *
23
+ * Out of scope here:
24
+ * - Logical operators inside the record (`$or`, `$and`) — callers
25
+ * who need them should construct the IR directly.
26
+ * - Mongo's `$ne` / `$exists` etc. with leading `$` — kits sanitize
27
+ * dangerous operators upstream; if hosts want raw record-mongo
28
+ * syntax they reach for `compileFilterToMongo` (mongokit-specific).
29
+ */
30
+ /**
31
+ * True when the value looks like an operator object — a non-array
32
+ * non-Date object whose keys are all known operators. Anything else
33
+ * is treated as a literal value (including `Date`, arrays, primitives).
34
+ */
35
+ function isOperatorObject(value) {
36
+ if (value === null || typeof value !== "object") return false;
37
+ if (Array.isArray(value)) return false;
38
+ if (value instanceof Date) return false;
39
+ const keys = Object.keys(value);
40
+ if (keys.length === 0) return false;
41
+ return keys.every((k) => KNOWN_OPS.has(k));
42
+ }
43
+ const KNOWN_OPS = new Set([
44
+ "eq",
45
+ "ne",
46
+ "gt",
47
+ "gte",
48
+ "lt",
49
+ "lte",
50
+ "in",
51
+ "nin",
52
+ "like",
53
+ "contains",
54
+ "startsWith",
55
+ "endsWith",
56
+ "exists"
57
+ ]);
58
+ /**
59
+ * Convert a single `field: value` pair into one Filter IR leaf (or
60
+ * an `and(...)` node when the value carries multiple operators).
61
+ */
62
+ function leafFromRecord(field, value) {
63
+ if (value === null) return isNull(field);
64
+ if (value === void 0) return TRUE;
65
+ if (!isOperatorObject(value)) {
66
+ if (Array.isArray(value)) return in_(field, value);
67
+ return eq(field, value);
68
+ }
69
+ const ops = [];
70
+ for (const [op, v] of Object.entries(value)) switch (op) {
71
+ case "eq":
72
+ ops.push(v === null ? isNull(field) : eq(field, v));
73
+ break;
74
+ case "ne":
75
+ ops.push(v === null ? isNotNull(field) : ne(field, v));
76
+ break;
77
+ case "gt":
78
+ ops.push(gt(field, v));
79
+ break;
80
+ case "gte":
81
+ ops.push(gte(field, v));
82
+ break;
83
+ case "lt":
84
+ ops.push(lt(field, v));
85
+ break;
86
+ case "lte":
87
+ ops.push(lte(field, v));
88
+ break;
89
+ case "in":
90
+ ops.push(in_(field, v));
91
+ break;
92
+ case "nin":
93
+ ops.push(nin(field, v));
94
+ break;
95
+ case "like":
96
+ ops.push(like(field, v));
97
+ break;
98
+ case "contains":
99
+ ops.push(contains(field, v));
100
+ break;
101
+ case "startsWith":
102
+ ops.push(startsWith(field, v));
103
+ break;
104
+ case "endsWith":
105
+ ops.push(endsWith(field, v));
106
+ break;
107
+ case "exists":
108
+ ops.push(v ? isNotNull(field) : isNull(field));
109
+ break;
110
+ }
111
+ if (ops.length === 0) return TRUE;
112
+ if (ops.length === 1) return ops[0];
113
+ return and(...ops);
114
+ }
115
+ /**
116
+ * Convert a plain record (Mongo-style query object) into a Filter IR
117
+ * tree. Multiple top-level keys AND together. Empty record → `TRUE`.
118
+ *
119
+ * Already-IR inputs (anything with a `.op` field) pass through
120
+ * unchanged so callers can mix-and-match without branching.
121
+ */
122
+ function recordToFilter(input) {
123
+ if (input && typeof input === "object" && "op" in input && typeof input.op === "string") return input;
124
+ const record = input;
125
+ const leaves = [];
126
+ for (const [field, value] of Object.entries(record)) {
127
+ if (value === void 0) continue;
128
+ leaves.push(leafFromRecord(field, value));
129
+ }
130
+ if (leaves.length === 0) return TRUE;
131
+ if (leaves.length === 1) return leaves[0];
132
+ return and(...leaves);
133
+ }
134
+ //#endregion
135
+ export { recordToFilter };
@@ -1,7 +1,8 @@
1
1
  import { Filter, FilterAnd, FilterEq, FilterExists, FilterFalse, FilterGt, FilterGte, FilterIn, FilterLike, FilterLt, FilterLte, FilterNe, FilterNin, FilterNot, FilterOp, FilterOr, FilterRaw, FilterRegex, FilterTrue } from "./types.mjs";
2
2
  import { FALSE, TRUE, and, between, contains, endsWith, eq, exists, gt, gte, iEq, in_, isNotNull, isNull, like, lt, lte, ne, nin, not, or, raw, regex, startsWith } from "./builders.mjs";
3
+ import { recordToFilter } from "./from-record.mjs";
3
4
  import { isFilter } from "./guard.mjs";
4
5
  import { asPredicate, matchFilter } from "./match.mjs";
5
6
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
6
7
  import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
7
- export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, regex, startsWith, walkFilter };
8
+ export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
@@ -1,6 +1,7 @@
1
1
  import { FALSE, TRUE, and, between, contains, endsWith, eq, exists, gt, gte, iEq, in_, isNotNull, isNull, like, lt, lte, ne, nin, not, or, raw, regex, startsWith } from "./builders.mjs";
2
+ import { recordToFilter } from "./from-record.mjs";
2
3
  import { isFilter } from "./guard.mjs";
3
4
  import { asPredicate, matchFilter } from "./match.mjs";
4
5
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
5
6
  import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
6
- export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, regex, startsWith, walkFilter };
7
+ export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
@@ -12,7 +12,13 @@
12
12
  * compose identically.
13
13
  */
14
14
  declare const HOOK_PRIORITY: {
15
- /** Policy enforcement — tenant isolation, soft-delete filtering, validation. */readonly POLICY: 100; /** Cache lookup / store — must run after policy so filters are in the key. */
15
+ /** Policy enforcement — tenant isolation, soft-delete filtering. */readonly POLICY: 100;
16
+ /**
17
+ * Input validation — Standard Schema / custom validators. Runs after
18
+ * policy (so tenant-stamped fields are present on the payload) and
19
+ * before cache (an invalid request must never claim a cache slot).
20
+ */
21
+ readonly VALIDATION: 150; /** Cache lookup / store — must run after policy so filters are in the key. */
16
22
  readonly CACHE: 200; /** Observability — audit logging, metrics, telemetry. Must not mutate context. */
17
23
  readonly OBSERVABILITY: 300; /** Default priority for user-registered hooks with no explicit priority. */
18
24
  readonly DEFAULT: 500;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  const HOOK_PRIORITY = {
15
15
  POLICY: 100,
16
+ VALIDATION: 150,
16
17
  CACHE: 200,
17
18
  OBSERVABILITY: 300,
18
19
  DEFAULT: 500
@@ -1,6 +1,8 @@
1
1
  import { RepositoryContext } from "../context/types.mjs";
2
+ import { RepositoryEventsOptions } from "../events/emit.mjs";
2
3
  import { HookListener, HookMode } from "../hooks/types.mjs";
3
4
  import { HookEngine } from "../hooks/engine.mjs";
5
+ import { StandardSchemaV1 } from "../schema/standard-schema.mjs";
4
6
  import { PluginType } from "./plugin-types.mjs";
5
7
 
6
8
  //#region src/repository/base.d.ts
@@ -16,6 +18,30 @@ interface RepositoryBaseOptions {
16
18
  pluginOrderChecks?: 'warn' | 'throw' | 'off';
17
19
  /** Optional callback for plugin-order warnings (defaults to `console.warn`). */
18
20
  onPluginOrderWarning?: (message: string) => void;
21
+ /**
22
+ * Standard Schema validator (Zod 3.24+, Valibot 1+, ArkType 2+, ...) for
23
+ * write payloads. Validates `create` data and every `createMany` doc at
24
+ * `HOOK_PRIORITY.VALIDATION` — after policy plugins (tenant-stamped fields
25
+ * are present), before cache/observability. Failures throw an `HttpError`
26
+ * 400 with structured `validationErrors`.
27
+ *
28
+ * Validator output replaces the payload, so coercions/defaults declared in
29
+ * the schema flow into the write.
30
+ */
31
+ schema?: StandardSchemaV1;
32
+ /**
33
+ * Standard Schema validator for `update` payloads. Separate slot because
34
+ * updates are partial — derive one explicitly (`schema.partial()` in Zod)
35
+ * rather than having repo-core guess partial semantics per vendor.
36
+ */
37
+ updateSchema?: StandardSchemaV1;
38
+ /**
39
+ * Domain-event emission. Pass any arc / `@classytic/primitives`-compatible
40
+ * transport and every mutating op publishes `<resource>.<verb>` events
41
+ * (`user.created`, `user.updated`, ...). Omit and the wiring is inert.
42
+ * See `@classytic/repo-core/events` for naming + delivery semantics.
43
+ */
44
+ events?: RepositoryEventsOptions;
19
45
  }
20
46
  /**
21
47
  * Base class every driver kit extends. Exposes the hook surface
@@ -35,6 +61,12 @@ declare abstract class RepositoryBase {
35
61
  */
36
62
  [key: string]: unknown;
37
63
  constructor(options: RepositoryBaseOptions);
64
+ /**
65
+ * Wire Standard Schema validation into the write lifecycle. Validator
66
+ * output replaces the payload so schema-declared coercions and defaults
67
+ * flow into the write.
68
+ */
69
+ private _registerSchemaValidation;
38
70
  /** Install a plugin (object with `apply(repo)` or a plain function). */
39
71
  use(plugin: PluginType): this;
40
72
  /**
@@ -1,4 +1,7 @@
1
1
  import { HookEngine } from "../hooks/engine.mjs";
2
+ import { HOOK_PRIORITY } from "../hooks/priority.mjs";
3
+ import { registerRepositoryEvents } from "../events/emit.mjs";
4
+ import { validateStandardSchema } from "../schema/standard-schema.mjs";
2
5
  import { validatePluginOrder } from "./plugin-types.mjs";
3
6
  //#region src/repository/base.ts
4
7
  /**
@@ -18,6 +21,28 @@ var RepositoryBase = class {
18
21
  for (let i = 0; i < plugins.length; i++) assertValidPlugin(plugins[i], this.modelName, i);
19
22
  validatePluginOrder(plugins, this.modelName, options.pluginOrderChecks ?? "warn", options.onPluginOrderWarning);
20
23
  for (const plugin of plugins) this.use(plugin);
24
+ if (options.schema) this._registerSchemaValidation(options.schema, options.updateSchema);
25
+ if (options.events) registerRepositoryEvents(this, options.events);
26
+ }
27
+ /**
28
+ * Wire Standard Schema validation into the write lifecycle. Validator
29
+ * output replaces the payload so schema-declared coercions and defaults
30
+ * flow into the write.
31
+ */
32
+ _registerSchemaValidation(schema, updateSchema) {
33
+ const validation = { priority: HOOK_PRIORITY.VALIDATION };
34
+ this.on("before:create", async (context) => {
35
+ if (context.data === void 0) return;
36
+ context.data = await validateStandardSchema(schema, context.data);
37
+ }, validation);
38
+ this.on("before:createMany", async (context) => {
39
+ if (!Array.isArray(context.dataArray)) return;
40
+ context.dataArray = await Promise.all(context.dataArray.map((doc) => validateStandardSchema(schema, doc)));
41
+ }, validation);
42
+ if (updateSchema) this.on("before:update", async (context) => {
43
+ if (context.data === void 0) return;
44
+ context.data = await validateStandardSchema(updateSchema, context.data);
45
+ }, validation);
21
46
  }
22
47
  /** Install a plugin (object with `apply(repo)` or a plain function). */
23
48
  use(plugin) {