Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# @suaveplan/events-schema - Runtime schema-validation companion for @suaveplan/events: validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and sensitive field redaction.
## Summary
Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and sensitive field redaction.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas } from "@suaveplan/events-schema";
import z from "zod";
const bus = new EventBus();
const handle = attachEventSchemas(bus, { mode: "strict" });
handle.registerSchema("user.created", z.object({ id: z.string() }));
await bus.emitAsync("user.created", { id: "u-1" }); // passes
await bus.emitAsync("user.created", { id: 123 }); // throws EventsSchemaError
handle.detach();
```
## Modules
- [`src/core/attach-event-schemas/attach-event-schemas`](src/core/attach-event-schemas/attach-event-schemas.md) — `attachEventSchemas`, `registerEventSchema`, `EventSchemasHandle`

View File

@@ -0,0 +1,75 @@
{
"name": "@suaveplan/events-schema",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Runtime schema-validation companion for @suaveplan/events: validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and sensitive field redaction.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/attach-event-schemas": {
"import": {
"types": "./dist/core/attach-event-schemas/attach-event-schemas.d.ts",
"default": "./dist/core/attach-event-schemas/attach-event-schemas.js"
},
"require": {
"types": "./dist/core/attach-event-schemas/attach-event-schemas.d.ts",
"default": "./dist/core/attach-event-schemas/attach-event-schemas.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/schema-registry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,2 @@
/** Default schema version for event registration. */
export const DEFAULT_SCHEMA_VERSION = "1.0.0";

View File

@@ -0,0 +1,97 @@
# attach-event-schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches runtime schema-validation instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. On each dispatch, it validates the event payload against a Zod schema registered for that event name. Three validation modes provide flexibility across different deployment stages: `"strict"` rejects the dispatch with a structured error (suitable for CI and development), `"warn"` logs the rejection but lets the event through (suitable for canary rollout), and `"sample"` validates a configurable fraction of events (suitable for high-throughput production paths).
Validation statistics are tracked in-process and available without an OTel exporter via `EventSchemasHandle.stats()`. OTel counters and histograms are also emitted for dashboard visibility.
## Features
- `attachEventSchemas(bus, options?)` — installs a schema-validation interceptor and returns `EventSchemasHandle`
- Three validation modes: `strict`, `warn`, `sample`
- `EventSchemasHandle.registerSchema(eventName, schema, version?)` — registers/updates a Zod schema
- `EventSchemasHandle.stats()` — returns in-process validation statistics
- Sensitive field redaction in rejection output (opt-in via `redactSensitive: true`)
- OTel counters: `events_schema.validations_total`, `events_schema.validations_failed_total`, `events_schema.unknown_schema_total`
- OTel histogram: `events_schema.validation_duration_ms`
- `registerEventSchema(registry, eventName, schema, options?)` — standalone registration helper
- Injectable `registry`, `clock`, `rng`, `logger` for testability
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas } from "@suaveplan/events-schema";
import z from "zod";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventSchemas(bus);
handle.registerSchema("user.created", z.object({ id: z.string() }));
bus.on("user.created", (p) => console.log("created", p.id));
await bus.emitAsync("user.created", { id: "u-1" }); // passes
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas, registerEventSchema } from "@suaveplan/events-schema";
import { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
const registry = new SchemaRegistry();
const bus = new EventBus();
const handle = attachEventSchemas(bus, {
registry,
mode: "warn",
onRejection: (r) => console.warn("REJECTED", r.eventName, r.errors),
redactSensitive: true,
});
handle.registerSchema("order.placed", z.object({
orderId: z.string(),
amount: z.number().positive(),
}));
await bus.emitAsync("order.placed", { orderId: "o-1", amount: -5 });
// Logs a warning but does NOT throw (warn mode)
console.log(handle.stats().rejectionsTotal["order.placed"]); // 1
handle.detach();
```
## API Reference
### `attachEventSchemas<TMap>(bus, options?): EventSchemasHandle`
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventSchemasOptions?` | Registry, mode, sampleRate, onRejection, redact, clock, rng, logger |
**Returns**: `EventSchemasHandle`
### `registerEventSchema(registry, eventName, schema, options?): void`
Standalone helper for registering schemas in a `SchemaRegistry` before attaching.
### `EventSchemasHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the interceptor |
| `registerSchema(eventName, schema, version?)` | `void` | Register a Zod schema |
| `stats()` | `SchemaValidationStats` | In-process statistics |
## Implementation Notes
Schemas registered via `handle.registerSchema` are stored in an in-memory `Map` in addition to the registry. This avoids the registry's compatibility enforcement on the first registration (which would fail with `SchemaRegistryError` for new subjects). Subsequent registrations via `registerSchema` go through the registry's compatibility check. The `sample` mode evaluates the random gate before schema lookup to avoid the lookup cost for unsampled dispatches. The `rng.next()` call uses the injectable `Rng` interface from `@suaveplan/types` for deterministic testing.

View File

@@ -0,0 +1,321 @@
import { EventBus } from "@suaveplan/events";
import { SchemaRegistry } from "@suaveplan/schema-registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import {
EventsSchemaError,
registerEventsSchemaErrorCodes,
} from "../../error-codes.js";
import {
attachEventSchemas,
registerEventSchema,
} from "./attach-event-schemas.js";
import type { ValidationRejection } from "./attach-event-schemas.types.js";
type TestMap = {
click: { x: number };
submit: { formId: string };
};
describe("attachEventSchemas", () => {
it("passes valid payload in strict mode", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "strict" });
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
handle.detach();
});
it("throws on invalid payload in strict mode", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "strict" });
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
let caught: unknown;
try {
await bus.emitAsync("click", { x: "not-a-number" } as any);
} catch (e) {
caught = e;
}
// EventsSchemaError is thrown inside interceptor; event bus may wrap it.
// Check either direct or via cause chain.
const isSchemaError = (err: unknown): boolean =>
err instanceof EventsSchemaError ||
(err instanceof Error && isSchemaError(err.cause));
expect(isSchemaError(caught)).toBe(true);
handle.detach();
});
it("does not throw on invalid payload in warn mode", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "warn" });
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(
bus.emitAsync("click", { x: "bad" } as any)
).resolves.toBeDefined();
handle.detach();
});
it("calls onRejection handler on failure", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const rejections: unknown[] = [];
const handle = attachEventSchemas(bus, {
mode: "warn",
onRejection: (r) => rejections.push(r),
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: "bad" } as any);
expect(rejections.length).toBe(1);
handle.detach();
});
it("passes through events with no schema registered", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "strict" });
bus.on("submit", () => void 0);
await expect(
bus.emitAsync("submit", { formId: "f" })
).resolves.toBeDefined();
const s = handle.stats();
expect(s.unknownSchemaTotal).toBe(1);
handle.detach();
});
it("sample mode skips validation at rate 0", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, {
mode: "sample",
sampleRate: 0,
rng: { next: () => 0.5 }, // 0.5 >= 0 so always skip
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(
bus.emitAsync("click", { x: "bad" } as any)
).resolves.toBeDefined();
expect(handle.stats().validationsTotal["click"]).toBeUndefined();
handle.detach();
});
it("sample mode validates at rate 1 always", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, {
mode: "sample",
sampleRate: 1.0,
rng: { next: () => 0.0 }, // 0.0 < 1.0 so always validate
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
expect(handle.stats().validationsTotal["click"]).toBe(1);
handle.detach();
});
it("stats tracks validations and rejections", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "warn" });
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("click", { x: "bad" } as any);
const s = handle.stats();
expect(s.validationsTotal["click"]).toBe(2);
expect(s.rejectionsTotal["click"]).toBe(1);
expect(s.avgValidationMs["click"]).toBeGreaterThanOrEqual(0);
handle.detach();
});
it("detach removes the interceptor", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "strict" });
handle.registerSchema("click", z.object({ x: z.number() }));
handle.detach();
bus.on("click", () => void 0);
await expect(
bus.emitAsync("click", { x: "bad" } as any)
).resolves.toBeDefined();
});
it("uses injectable clock for latency", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
let t = 100;
const clock = { now: () => (t += 5) };
const handle = attachEventSchemas(bus, { mode: "warn", clock });
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
const s = handle.stats();
expect(s.avgValidationMs["click"]).toBeGreaterThan(0);
handle.detach();
});
it("registers schema via registry and validates", {
timeout: 5000,
}, async () => {
const registry = new SchemaRegistry();
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, { mode: "strict", registry });
registerEventSchema(registry, "click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 5 })).resolves.toBeDefined();
handle.detach();
});
});
describe("attachEventSchemas option defaults", () => {
it("uses default options when called with only the bus", {
timeout: 5000,
}, () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus);
const s = handle.stats();
expect(s.unknownSchemaTotal).toBe(0);
handle.detach();
});
it("invokes default rng when sample mode and no rng injected", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, {
mode: "sample",
sampleRate: 1.0,
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
handle.detach();
});
});
describe("attachEventSchemas redactSensitive=false", () => {
it("includes the stringified payload in rejection.received", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const rejections: ValidationRejection[] = [];
const handle = attachEventSchemas(bus, {
mode: "warn",
redactSensitive: false,
onRejection: (r) => rejections.push(r),
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: "bad" } as unknown as { x: number });
expect(rejections).toHaveLength(1);
const first = rejections[0];
expect(first).toBeDefined();
expect(first?.errors[0]?.received).not.toBe("[REDACTED]");
handle.detach();
});
});
describe("registerEventsSchemaErrorCodes", () => {
it("is callable as an idempotent no-op hook", { timeout: 5000 }, () => {
expect(() => registerEventsSchemaErrorCodes()).not.toThrow();
expect(() => registerEventsSchemaErrorCodes()).not.toThrow();
});
});
describe("attachEventSchemas defensive branches", () => {
it("returns null from getSchema when registry def lacks safeParse", {
timeout: 5000,
}, async () => {
const fakeRegistry = {
getLatest: () => ({ schema: { not: "a-zod-schema" } }),
setCompatibility: () => {},
register: () => {},
} as unknown as SchemaRegistry;
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, {
mode: "strict",
registry: fakeRegistry,
});
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 1 })).resolves.toBeDefined();
const s = handle.stats();
expect(s.unknownSchemaTotal).toBe(1);
handle.detach();
});
it("registerSchema swallows registry rejection but keeps inline schema", {
timeout: 5000,
}, async () => {
const fakeRegistry = {
getLatest: () => {
throw new Error("not found");
},
setCompatibility: () => {
throw new Error("compat break");
},
register: () => {
throw new Error("compat break");
},
} as unknown as SchemaRegistry;
const bus = new EventBus<TestMap>();
const handle = attachEventSchemas(bus, {
mode: "strict",
registry: fakeRegistry,
});
handle.registerSchema("click", z.object({ x: z.number() }));
bus.on("click", () => void 0);
await expect(bus.emitAsync("click", { x: 7 })).resolves.toBeDefined();
handle.detach();
});
});
describe("registerEventSchema", () => {
it("registers a schema in the registry", { timeout: 5000 }, () => {
const registry = new SchemaRegistry();
registerEventSchema(
registry,
"test.event",
z.object({ id: z.string() })
);
const latest = registry.getLatest("test.event");
expect(latest).toBeDefined();
});
it("registers with description metadata", { timeout: 5000 }, () => {
const registry = new SchemaRegistry();
registerEventSchema(
registry,
"test.event2",
z.object({ id: z.string() }),
{
description: "test",
}
);
const latest = registry.getLatest("test.event2");
expect(latest).toBeDefined();
});
it("registers with custom compatibility", { timeout: 5000 }, () => {
const registry = new SchemaRegistry();
registerEventSchema(
registry,
"test.event3",
z.object({ id: z.string() }),
{
compatibility: "NONE",
}
);
const latest = registry.getLatest("test.event3");
expect(latest).toBeDefined();
});
});

View File

@@ -0,0 +1,211 @@
import { metrics } from "@opentelemetry/api";
import type { EventBus, EventMap } from "@suaveplan/events";
import { SchemaRegistry } from "@suaveplan/schema-registry";
import type { ZodSchema } from "zod";
import { EVENTS_SCHEMA_CODES, EventsSchemaError } from "../../error-codes.js";
import type {
EventSchemasHandle,
EventSchemasOptions,
SchemaRegistrationOptions,
ValidationError,
ValidationRejection,
} from "./attach-event-schemas.types.js";
const meter = metrics.getMeter("suaveplan.events.schema");
const validationCounter = meter.createCounter(
"events_schema.validations_total",
{
description: "Total schema validations",
}
);
const rejectionCounter = meter.createCounter(
"events_schema.validations_failed_total",
{
description: "Total schema validation rejections",
}
);
const unknownCounter = meter.createCounter(
"events_schema.unknown_schema_total",
{
description: "Total dispatches for events with no registered schema",
}
);
const latencyHistogram = meter.createHistogram(
"events_schema.validation_duration_ms",
{
description: "Schema validation latency in milliseconds",
unit: "ms",
}
);
/** Register a Zod schema for an event name in a SchemaRegistry. */
export function registerEventSchema(
registry: SchemaRegistry,
eventName: string,
schema: ZodSchema,
options?: SchemaRegistrationOptions
): void {
const compat = options?.compatibility ?? "BACKWARD";
registry.setCompatibility(eventName, compat);
registry.register(
eventName,
schema,
"json-schema",
options?.description ? { description: options.description } : undefined
);
}
/**
* Attaches schema-validation instrumentation to an EventBus via the interceptor API.
*/
export function attachEventSchemas<TMap extends EventMap>(
bus: EventBus<TMap>,
options: EventSchemasOptions = {}
): EventSchemasHandle {
const registry = options.registry ?? new SchemaRegistry();
const mode = options.mode ?? "strict";
const sampleRate = options.sampleRate ?? 1.0;
const redactSensitive = options.redactSensitive ?? true;
const onRejection = options.onRejection;
const clock = options.clock ?? { now: () => Date.now() };
const rng = options.rng ?? { next: () => Math.random() };
// In-process stats accumulators
const validationsTotal: Record<string, number> = {};
const rejectionsTotal: Record<string, number> = {};
const latencyAccum: Record<string, { sum: number; count: number }> = {};
let unknownSchemaTotal = 0;
// In-memory map for schemas registered via handle.registerSchema
const inlineSchemas = new Map<string, ZodSchema>();
function getSchema(eventName: string): ZodSchema | null {
const inline = inlineSchemas.get(eventName);
if (inline) return inline;
try {
const def = registry.getLatest(eventName);
// Schema stored in registry is the raw ZodSchema object passed to register()
const s = def.schema;
if (s && typeof (s as ZodSchema).safeParse === "function") {
return s as ZodSchema;
}
return null;
} catch {
return null;
}
}
function buildRejection(
eventName: string,
eventId: string,
payload: unknown,
zodErrors: { path: PropertyKey[]; message: string }[]
): ValidationRejection {
const errors: ValidationError[] = zodErrors.map((e) => ({
path: e.path.map(String).join("."),
message: e.message,
received: redactSensitive ? "[REDACTED]" : String(payload),
}));
return {
eventName,
eventId,
payload,
errors,
mode,
timestamp: new Date(clock.now()),
};
}
const unsubscribe = bus.use(async (envelope, next) => {
const eventName = envelope.type;
const eventId = envelope.id;
const payload = envelope.payload;
const schema = getSchema(eventName);
// Sample mode: skip based on rng
if (mode === "sample" && rng.next() >= sampleRate) {
return next();
}
if (!schema) {
unknownSchemaTotal++;
unknownCounter.add(1, { "event.name": eventName });
return next();
}
const startMs = clock.now();
const result = schema.safeParse(payload);
const elapsed = clock.now() - startMs;
validationsTotal[eventName] = (validationsTotal[eventName] ?? 0) + 1;
validationCounter.add(1, {
"event.name": eventName,
"validation.result": result.success ? "pass" : "fail",
});
latencyHistogram.record(elapsed, { "event.name": eventName });
const accum = latencyAccum[eventName] ?? { sum: 0, count: 0 };
accum.sum += elapsed;
accum.count += 1;
latencyAccum[eventName] = accum;
if (!result.success) {
rejectionsTotal[eventName] = (rejectionsTotal[eventName] ?? 0) + 1;
rejectionCounter.add(1, { "event.name": eventName });
const rejection = buildRejection(
eventName,
eventId,
payload,
result.error.issues
);
onRejection?.(rejection);
if (mode === "strict") {
throw new EventsSchemaError(
EVENTS_SCHEMA_CODES.VALIDATION_FAILED,
`Schema validation failed for event "${eventName}": ${result.error.issues.map((e) => e.message).join(", ")}`
);
}
// warn mode: log and pass through
options.logger?.warn?.("events-schema.validation_failed", {
eventName,
errors: rejection.errors,
});
}
return next();
});
return {
detach(): void {
unsubscribe();
},
registerSchema(
eventName: string,
schema: ZodSchema,
_version?: string
): void {
inlineSchemas.set(eventName, schema);
try {
registry.setCompatibility(eventName, "BACKWARD");
registry.register(eventName, schema, "json-schema");
} catch {
// Registry might reject due to compatibility; inline map is the source of truth
}
},
stats(): ReturnType<EventSchemasHandle["stats"]> {
const avgValidationMs: Record<string, number> = {};
for (const [name, accum] of Object.entries(latencyAccum)) {
avgValidationMs[name] = accum.sum / accum.count;
}
return {
validationsTotal: { ...validationsTotal },
rejectionsTotal: { ...rejectionsTotal },
avgValidationMs,
unknownSchemaTotal,
};
},
};
}

View File

@@ -0,0 +1,89 @@
import type { SchemaRegistry } from "@suaveplan/schema-registry";
import type { LoggerLike } from "@suaveplan/types";
import type { ZodSchema } from "zod";
/** Minimal clock interface for schema validation timing. */
export interface SchemaClock {
now(): number;
}
/** Minimal RNG interface for sample mode. */
export interface SchemaRng {
next(): number;
}
/** Validation mode controlling behaviour on schema failure. */
export type ValidationMode = "strict" | "warn" | "sample";
/** Handler called for every validation rejection. */
export type RejectionHandler = (rejection: ValidationRejection) => void;
/** Options for schema-validation companion. */
export interface EventSchemasOptions {
/** Schema registry instance. If omitted, a default in-memory registry is created. */
registry?: SchemaRegistry;
/** Validation mode. Default: "strict". */
mode?: ValidationMode;
/** Sample rate (0–1) when mode === "sample". Default: 1.0. */
sampleRate?: number;
/** Called on every validation rejection. */
onRejection?: RejectionHandler;
/** Redact sensitive field values in rejection output. Default: true. */
redactSensitive?: boolean;
/** Injectable clock. */
clock?: SchemaClock;
/** Injectable RNG for sample mode. */
rng?: SchemaRng;
/** Diagnostic logger. */
logger?: LoggerLike;
}
/** A single field-level validation error. */
export interface ValidationError {
path: string;
message: string;
expected?: string;
received?: string;
}
/** Rejection record emitted when a payload fails validation. */
export interface ValidationRejection {
eventName: string;
eventId?: string;
payload: unknown;
errors: ValidationError[];
mode: ValidationMode;
timestamp: Date;
}
/** Statistics about schema validations performed. */
export interface SchemaValidationStats {
validationsTotal: Record<string, number>;
rejectionsTotal: Record<string, number>;
avgValidationMs: Record<string, number>;
unknownSchemaTotal: number;
}
/** Handle returned by attachEventSchemas. */
export interface EventSchemasHandle {
/** Remove the schema-validation interceptor. */
detach(): void;
/** Register or update a Zod schema for an event name. */
registerSchema(
eventName: string,
schema: ZodSchema,
version?: string
): void;
/** Return validation statistics. */
stats(): SchemaValidationStats;
}
/** Options for standalone schema registration. */
export interface SchemaRegistrationOptions {
/** Schema version string. Default: "1.0.0". */
version?: string;
/** Human-readable description. */
description?: string;
/** Compatibility level for the schema registry. Default: "BACKWARD". */
compatibility?: "BACKWARD" | "FORWARD" | "FULL" | "NONE";
}

View File

@@ -0,0 +1,13 @@
export {
attachEventSchemas,
registerEventSchema,
} from "./attach-event-schemas.js";
export type {
EventSchemasHandle,
EventSchemasOptions,
SchemaRegistrationOptions,
SchemaValidationStats,
ValidationError,
ValidationMode,
ValidationRejection,
} from "./attach-event-schemas.types.js";

View File

@@ -0,0 +1,34 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const EVENTS_SCHEMA_CODES = defineCodes("EVENTS_SCHEMA", [
"VALIDATION_FAILED",
"UNKNOWN",
"COMPATIBILITY_BREAK",
"ATTACH_FAILED",
] as const);
export type EventsSchemaErrorCode =
(typeof EVENTS_SCHEMA_CODES)[keyof typeof EVENTS_SCHEMA_CODES];
/**
* Structured error for events-schema operations.
*/
export class EventsSchemaError extends SuaveplanError<EventsSchemaErrorCode> {
public constructor(
code: EventsSchemaErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "EventsSchemaError";
}
}
/** Register events-schema error codes (idempotent). */
export function registerEventsSchemaErrorCodes(): void {
// defineCodes handles registration; this is a no-op hook.
}

View File

@@ -0,0 +1,20 @@
export {
attachEventSchemas,
registerEventSchema,
} from "./core/attach-event-schemas/attach-event-schemas.js";
export type {
EventSchemasHandle,
EventSchemasOptions,
RejectionHandler,
SchemaRegistrationOptions,
SchemaValidationStats,
ValidationError,
ValidationMode,
ValidationRejection,
} from "./core/attach-event-schemas/attach-event-schemas.types.js";
export type { EventsSchemaErrorCode } from "./error-codes.js";
export {
EVENTS_SCHEMA_CODES,
EventsSchemaError,
registerEventsSchemaErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("package has correct name", { timeout: 5000 }, async () => {
const pkg = await import("../package.json", { with: { type: "json" } });
expect(pkg.default.name).toBe("@suaveplan/events-schema");
});
it("exports attachEventSchemas", { timeout: 30000 }, async () => {
const { attachEventSchemas } = await import("../src/index.js");
expect(typeof attachEventSchemas).toBe("function");
});
it("exports EventsSchemaError", { timeout: 30000 }, async () => {
const { EventsSchemaError } = await import("../src/index.js");
expect(typeof EventsSchemaError).toBe("function");
});
});

View File

@@ -0,0 +1,10 @@
import { webcrypto } from "node:crypto";
// Polyfill Web Crypto for Node.js environments (Vitest forks pool)
if (typeof globalThis.crypto === "undefined") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
writable: false,
configurable: true,
});
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/events-schema",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/schema-registry",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});