@classytic/repo-core 0.5.0 → 0.6.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 +20 -1
- package/README.md +65 -3
- package/dist/events/emit.d.mts +22 -0
- package/dist/events/emit.mjs +105 -0
- package/dist/events/index.d.mts +3 -0
- package/dist/events/index.mjs +2 -0
- package/dist/events/types.d.mts +73 -0
- package/dist/filter/from-record.d.mts +13 -0
- package/dist/filter/from-record.mjs +135 -0
- package/dist/filter/index.d.mts +2 -1
- package/dist/filter/index.mjs +2 -1
- package/dist/hooks/priority.d.mts +7 -1
- package/dist/hooks/priority.mjs +1 -0
- package/dist/repository/base.d.mts +32 -0
- package/dist/repository/base.mjs +25 -0
- package/dist/repository/capabilities.d.mts +159 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +2 -1
- package/dist/repository/options.d.mts +5 -1
- package/dist/repository/options.mjs +6 -1
- package/dist/repository/purge.mjs +21 -21
- package/dist/repository/resilience.d.mts +55 -0
- package/dist/repository/resilience.mjs +39 -0
- package/dist/repository/types.d.mts +73 -14
- package/dist/schema/index.d.mts +2 -1
- package/dist/schema/index.mjs +2 -1
- package/dist/schema/standard-schema.d.mts +89 -0
- package/dist/schema/standard-schema.mjs +59 -0
- package/dist/testing/index.d.mts +2 -1
- package/dist/testing/types.d.mts +12 -112
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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.0] - 2026-06-11
|
|
8
|
+
|
|
9
|
+
Standardization release. Coordinated with mongokit 3.16 + sqlitekit 0.6.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
- **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`.
|
|
15
|
+
- **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`).
|
|
16
|
+
- **Change feed contract** — optional `StandardRepo.watch?()` returning `AsyncIterable<ChangeEvent<TDoc>>`; gated by `capabilities.changeStreams`.
|
|
17
|
+
- **Resilience** — `QueryOptions.signal` + `QueryOptions.retryPolicy`; shared `withRetry()` / `throwIfAborted()` helpers.
|
|
18
|
+
- **`recordToFilter()`** on `/filter` — promoted from per-kit copies.
|
|
19
|
+
- `STANDARD_REPO_OPTION_KEYS` gains `'traceId'`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `PurgeRetryPolicy` → **`RetryPolicy`** (rename, identical shape; was declared twice — now once). Clean break, no alias.
|
|
24
|
+
- `AggregateOpsSupport` moved to `repository/capabilities.ts` (still re-exported from `/testing`).
|
|
25
|
+
|
|
7
26
|
## [0.5.0] - 2026-05-17
|
|
8
27
|
|
|
9
28
|
### Added — compliance-grade tenant cleanup primitive
|
|
@@ -28,7 +47,7 @@ on top.
|
|
|
28
47
|
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
48
|
|
|
30
49
|
- **`runChunkedPurge(strategy, options, port)`** — pure orchestrator (130 lines, no I/O).
|
|
31
|
-
- **`PurgePort`** interface — the driving port. Each kit implements
|
|
50
|
+
- **`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
51
|
- **`WritingPurgeStrategy`** — strategy union with `skip` excluded (orchestrator handles `skip` before the port is consulted, so ports only see `hard` / `soft` / `anonymize`).
|
|
33
52
|
|
|
34
53
|
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.
|
|
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.
|
|
168
|
-
- `@classytic/sqlitekit` ≥ 0.
|
|
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,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 };
|
package/dist/filter/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/filter/index.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/dist/hooks/priority.mjs
CHANGED
|
@@ -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
|
/**
|
package/dist/repository/base.mjs
CHANGED
|
@@ -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) {
|