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,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";