Initial Commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/** Default schema version for event registration. */
|
||||
export const DEFAULT_SCHEMA_VERSION = "1.0.0";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
34
packages/networking/events/events-schema/src/error-codes.ts
Normal file
34
packages/networking/events/events-schema/src/error-codes.ts
Normal 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.
|
||||
}
|
||||
20
packages/networking/events/events-schema/src/index.ts
Normal file
20
packages/networking/events/events-schema/src/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user