From 289956db5a54615d85489e68c946e131875b3de7 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 14:07:59 +0300 Subject: [PATCH 1/2] feat: add typed distribution event catalog with opt-in emit-time validation - src/catalog.ts: EventTypeCatalog registry binding envelope type strings to @hasna/contracts schema ids (release.published, release.rollout.started/ completed/failed, app.installed, announcement.sent, feedback.created, feedback.triaged) with dependency-free structural payload validators and vendored payload type mirrors - EventsClient: optional catalog + validateCatalogTypes options and a per-emit validate override; invalid registered-type payloads throw EventValidationError before the event is stored or delivered - backward compatible: validation is off by default and unregistered free-form event types always pass - export ./catalog subpath, build entry, README docs, and unit tests for both validation directions --- README.md | 45 ++++++ package.json | 6 +- src/catalog.test.ts | 215 ++++++++++++++++++++++++++++ src/catalog.ts | 332 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 24 ++++ src/types.ts | 8 ++ 6 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 src/catalog.test.ts create mode 100644 src/catalog.ts diff --git a/README.md b/README.md index b6e01a6..3994cf8 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,51 @@ Envelope fields are: `source` should be the emitting app or bounded context. `type` should use dot notation such as `ticket.created`, `repo.synced`, or `check.failed`. +## Typed Event Catalog (Distribution Events) + +`@hasna/events/catalog` binds well-known envelope `type` strings to the +`@hasna/contracts` schema ids their `data` payloads mirror, and provides an +OPT-IN emit-time validator hook. + +Distribution event types (`DISTRIBUTION_EVENT_TYPES`): + +| Event type | Contracts schema (`data` mirror) | +| --- | --- | +| `release.published` | `hasna.release.v1` | +| `release.rollout.started` | `hasna.rollout_record.v1` | +| `release.rollout.completed` | `hasna.rollout_record.v1` | +| `release.rollout.failed` | `hasna.rollout_record.v1` | +| `app.installed` | `hasna.rollout_record.v1` | +| `announcement.sent` | `hasna.announcement.v1` | +| `feedback.created` | `hasna.feedback.v1` | +| `feedback.triaged` | `hasna.feedback.v1` | + +```ts +import { EventsClient, EventTypeCatalog, registerDistributionEventTypes } from "@hasna/events"; + +const catalog = registerDistributionEventTypes(new EventTypeCatalog()); +const client = new EventsClient({ catalog, validateCatalogTypes: true }); + +// Registered type with an invalid payload throws EventValidationError +// BEFORE the event is stored or delivered. +await client.emit({ + source: "open-publish", + type: "release.published", + data: { appId: "open-todos", package: "@hasna/todos", version: "0.11.63" }, +}); +``` + +Validation is fully backward compatible: + +- It is OFF by default (`validateCatalogTypes` defaults to `false`). +- Unregistered/free-form event types ALWAYS pass, even when validation is on. +- A per-emit `validate` option overrides the client setting in both directions. + +Payload types (`ReleasePublishedData`, `RolloutData`, `AppInstalledData`, +`AnnouncementSentData`, `FeedbackCreatedData`, `FeedbackTriagedData`) are +dependency-free structural mirrors of the contracts schemas; this package does +not depend on `@hasna/contracts` at runtime. + ## OpenAutomations Trigger Ingress `@hasna/events` is trigger ingress for OpenAutomations. It records and delivers diff --git a/package.json b/package.json index 4ca7dd3..96b2d54 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,10 @@ "types": "./dist/transports.d.ts", "import": "./dist/transports.js" }, + "./catalog": { + "types": "./dist/catalog.d.ts", + "import": "./dist/catalog.js" + }, "./commander": { "types": "./dist/commander.d.ts", "import": "./dist/commander.js" @@ -45,7 +49,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/types.ts src/commander.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/types.ts src/commander.ts src/catalog.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test", "prepublishOnly": "bun run test && bun run build" diff --git a/src/catalog.test.ts b/src/catalog.test.ts new file mode 100644 index 0000000..8426047 --- /dev/null +++ b/src/catalog.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEvent, EventsClient } from "./index.js"; +import { JsonEventsStore } from "./storage.js"; +import { + createDistributionEventDefinitions, + DISTRIBUTION_EVENT_CONTRACT_SCHEMAS, + DISTRIBUTION_EVENT_TYPES, + EventTypeCatalog, + EventValidationError, + registerDistributionEventTypes, + validateAnnouncementSentData, + validateAppInstalledData, + validateFeedbackCreatedData, + validateFeedbackTriagedData, + validateReleasePublishedData, + validateRolloutData, + type ReleasePublishedData, +} from "./catalog.js"; + +let dataDir = ""; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "hasna-events-catalog-")); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +function makeCatalog(): EventTypeCatalog { + return registerDistributionEventTypes(new EventTypeCatalog()); +} + +const releaseData: ReleasePublishedData = { + appId: "open-todos", + package: "@hasna/todos", + version: "0.11.63", + gitSha: "9fceb02d0ae598e95dc970b74767f19372d61af8", + publishPath: "skill", +}; + +const rolloutData = { + appId: "open-todos", + package: "@hasna/todos", + version: "0.11.63", + machine: "spark01", + action: "update", + result: "succeeded", +} as const; + +describe("EventTypeCatalog", () => { + test("registers all distribution event types with contracts schema bindings", () => { + const catalog = makeCatalog(); + const types = Object.values(DISTRIBUTION_EVENT_TYPES); + expect(catalog.list().map((definition) => definition.type).sort()).toEqual([...types].sort()); + for (const type of types) { + expect(catalog.get(type)?.contractSchemaId).toBe(DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type]); + } + }); + + test("unregistered types always validate ok", () => { + const catalog = makeCatalog(); + const event = createEvent({ source: "tickets", type: "ticket.created", data: { anything: true } }); + expect(catalog.validateEvent(event)).toEqual({ ok: true }); + expect(() => catalog.assertEventValid(event)).not.toThrow(); + }); + + test("register and unregister round-trip", () => { + const catalog = new EventTypeCatalog(); + catalog.register({ type: "custom.type", validate: () => ({ ok: false, issues: [{ path: "x", message: "nope" }] }) }); + expect(catalog.has("custom.type")).toBe(true); + const event = createEvent({ source: "custom", type: "custom.type" }); + expect(catalog.validateEvent(event).ok).toBe(false); + expect(catalog.unregister("custom.type")).toBe(true); + expect(catalog.validateEvent(event)).toEqual({ ok: true }); + }); + + test("validates registered distribution payloads through validateEvent", () => { + const catalog = makeCatalog(); + const good = createEvent({ source: "open-publish", type: "release.published", data: releaseData }); + expect(catalog.validateEvent(good)).toEqual({ ok: true }); + + const bad = createEvent({ source: "open-publish", type: "release.published", data: { appId: "open-todos" } }); + const result = catalog.validateEvent(bad); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues.map((issue) => issue.path).sort()).toEqual(["package", "version"]); + } + expect(() => catalog.assertEventValid(bad)).toThrow(EventValidationError); + }); +}); + +describe("distribution payload validators", () => { + const envelope = (type: string, data: Record) => createEvent({ source: "test", type, data }); + + test("release.published requires appId/package/version and valid publishPath", () => { + expect(validateReleasePublishedData(releaseData, envelope("release.published", releaseData)).ok).toBe(true); + const bad = { ...releaseData, publishPath: "manual" }; + const result = validateReleasePublishedData(bad, envelope("release.published", bad)); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues[0]?.path).toBe("publishPath"); + }); + + test("rollout validators require machine and require result on completed/failed", () => { + expect(validateRolloutData(rolloutData, envelope("release.rollout.started", rolloutData)).ok).toBe(true); + + const noResult = { appId: "open-todos", package: "@hasna/todos", version: "0.11.63", machine: "spark01" }; + expect(validateRolloutData(noResult, envelope("release.rollout.started", noResult)).ok).toBe(true); + expect(validateRolloutData(noResult, envelope("release.rollout.completed", noResult)).ok).toBe(false); + expect(validateRolloutData(noResult, envelope("release.rollout.failed", noResult)).ok).toBe(false); + + const noMachine = { appId: "open-todos", package: "@hasna/todos", version: "0.11.63" }; + const result = validateRolloutData(noMachine, envelope("release.rollout.started", noMachine)); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues[0]?.path).toBe("machine"); + }); + + test("app.installed requires appId/package/version/machine", () => { + const good = { appId: "open-todos", package: "@hasna/todos", version: "0.11.63", machine: "spark01" }; + expect(validateAppInstalledData(good, envelope("app.installed", good)).ok).toBe(true); + expect(validateAppInstalledData({ ...good, machine: "" }, envelope("app.installed", good)).ok).toBe(false); + }); + + test("announcement.sent requires campaignId and string-array channels", () => { + const good = { campaignId: "campaign_1", audienceId: "oss-operators", channels: ["email", "telegram"] }; + expect(validateAnnouncementSentData(good, envelope("announcement.sent", good)).ok).toBe(true); + expect(validateAnnouncementSentData({ channels: ["email"] }, envelope("announcement.sent", {})).ok).toBe(false); + expect(validateAnnouncementSentData({ campaignId: "c", channels: [1] }, envelope("announcement.sent", {})).ok).toBe(false); + }); + + test("feedback validators require feedbackId and triage disposition", () => { + expect(validateFeedbackCreatedData({ feedbackId: "fb_1" }, envelope("feedback.created", {})).ok).toBe(true); + expect(validateFeedbackCreatedData({}, envelope("feedback.created", {})).ok).toBe(false); + expect(validateFeedbackTriagedData({ feedbackId: "fb_1", disposition: "bug" }, envelope("feedback.triaged", {})).ok).toBe(true); + expect(validateFeedbackTriagedData({ feedbackId: "fb_1" }, envelope("feedback.triaged", {})).ok).toBe(false); + }); + + test("createDistributionEventDefinitions returns fresh definitions", () => { + const first = createDistributionEventDefinitions(); + const second = createDistributionEventDefinitions(); + expect(first).not.toBe(second); + expect(first.map((definition) => definition.type)).toEqual(second.map((definition) => definition.type)); + }); +}); + +describe("EventsClient emit-time validator hook", () => { + const badRelease = { appId: "open-todos" }; + + test("validation is OFF by default: registered type with bad payload still emits", async () => { + const client = new EventsClient({ store: new JsonEventsStore(dataDir), catalog: makeCatalog() }); + const result = await client.emit({ source: "open-publish", type: "release.published", data: badRelease }); + expect(result.deduped).toBe(false); + expect(await client.listEvents()).toHaveLength(1); + }); + + test("opt-in validation rejects registered types with invalid payloads before storing", async () => { + const client = new EventsClient({ + store: new JsonEventsStore(dataDir), + catalog: makeCatalog(), + validateCatalogTypes: true, + }); + await expect( + client.emit({ source: "open-publish", type: "release.published", data: badRelease }), + ).rejects.toThrow(EventValidationError); + expect(await client.listEvents()).toHaveLength(0); + }); + + test("opt-in validation accepts registered types with valid payloads", async () => { + const client = new EventsClient({ + store: new JsonEventsStore(dataDir), + catalog: makeCatalog(), + validateCatalogTypes: true, + }); + const result = await client.emit({ source: "open-publish", type: "release.published", data: releaseData }); + expect(result.event.data.appId).toBe("open-todos"); + expect(await client.listEvents()).toHaveLength(1); + }); + + test("opt-in validation leaves unregistered/free-form types untouched", async () => { + const client = new EventsClient({ + store: new JsonEventsStore(dataDir), + catalog: makeCatalog(), + validateCatalogTypes: true, + }); + const result = await client.emit({ source: "tickets", type: "ticket.created", data: { whatever: 1 } }); + expect(result.deduped).toBe(false); + expect(await client.listEvents()).toHaveLength(1); + }); + + test("per-emit validate option overrides the client setting in both directions", async () => { + const catalog = makeCatalog(); + const offClient = new EventsClient({ store: new JsonEventsStore(dataDir), catalog }); + await expect( + offClient.emit({ source: "open-publish", type: "release.published", data: badRelease }, { validate: true }), + ).rejects.toThrow(EventValidationError); + + const onClient = new EventsClient({ store: new JsonEventsStore(dataDir), catalog, validateCatalogTypes: true }); + const result = await onClient.emit( + { source: "open-publish", type: "release.published", data: badRelease }, + { validate: false }, + ); + expect(result.deduped).toBe(false); + }); + + test("does not use the shared default catalog registrations unless opted in", async () => { + // Even if some other module registered the distribution types on the + // default catalog, clients that did not enable validation are untouched. + const client = new EventsClient({ store: new JsonEventsStore(dataDir) }); + const result = await client.emit({ source: "open-publish", type: "release.published", data: badRelease }); + expect(result.deduped).toBe(false); + }); +}); diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..053d53a --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,332 @@ +import type { EventData, EventEnvelope } from "./types.js"; + +// --------------------------------------------------------------------------- +// Typed event catalog (Hasna distribution apps plan) +// +// Binds event envelope `type` strings to the `@hasna/contracts` schema ids +// their `data` payloads mirror, and provides an OPT-IN emit-time validator +// hook. Validation is off by default and only ever applies to REGISTERED +// types: unregistered/free-form event types keep working untouched, so the +// ~50 packages that emit ad-hoc events through `@hasna/events` are unaffected +// unless they opt in. +// +// The payload types below are intentionally dependency-free structural +// mirrors of the corresponding `@hasna/contracts` distribution schemas +// (`hasna.app.v1`, `hasna.release.v1`, `hasna.rollout_record.v1`, +// `hasna.announcement.v1`, `hasna.audience.v1`). This package does not take a +// runtime dependency on `@hasna/contracts`. +// --------------------------------------------------------------------------- + +export interface EventValidationIssue { + path: string; + message: string; +} + +export type EventValidationResult = { ok: true } | { ok: false; issues: EventValidationIssue[] }; + +export type EventDataValidator = (data: EventData, event: EventEnvelope) => EventValidationResult; + +export interface EventTypeDefinition { + /** Envelope `type` string this definition binds, e.g. `release.published`. */ + type: string; + /** `@hasna/contracts` schema id the payload mirrors, e.g. `hasna.release.v1`. */ + contractSchemaId?: string; + description?: string; + validate: EventDataValidator; +} + +export class EventValidationError extends Error { + readonly eventType: string; + readonly issues: EventValidationIssue[]; + + constructor(eventType: string, issues: EventValidationIssue[]) { + const detail = issues.map((issue) => `${issue.path || ""}: ${issue.message}`).join("; "); + super(`Event validation failed for type "${eventType}": ${detail}`); + this.name = "EventValidationError"; + this.eventType = eventType; + this.issues = issues; + } +} + +export class EventTypeCatalog { + private definitions = new Map(); + + register(definition: EventTypeDefinition): this { + this.definitions.set(definition.type, definition); + return this; + } + + unregister(type: string): boolean { + return this.definitions.delete(type); + } + + has(type: string): boolean { + return this.definitions.has(type); + } + + get(type: string): EventTypeDefinition | undefined { + return this.definitions.get(type); + } + + list(): EventTypeDefinition[] { + return [...this.definitions.values()]; + } + + /** + * Validate an event against its registered definition. Events whose type is + * NOT registered always pass: free-form types stay untouched. + */ + validateEvent(event: EventEnvelope): EventValidationResult { + const definition = this.definitions.get(event.type); + if (!definition) return { ok: true }; + return definition.validate(event.data, event); + } + + /** Like {@link validateEvent} but throws {@link EventValidationError}. */ + assertEventValid(event: EventEnvelope): void { + const result = this.validateEvent(event); + if (!result.ok) { + throw new EventValidationError(event.type, result.issues); + } + } +} + +/** Shared default catalog used by `EventsClient` when none is provided. */ +export const defaultEventTypeCatalog = new EventTypeCatalog(); + +// --------------------------------------------------------------------------- +// Distribution event types +// --------------------------------------------------------------------------- + +export const DISTRIBUTION_EVENT_TYPES = { + releasePublished: "release.published", + rolloutStarted: "release.rollout.started", + rolloutCompleted: "release.rollout.completed", + rolloutFailed: "release.rollout.failed", + appInstalled: "app.installed", + announcementSent: "announcement.sent", + feedbackCreated: "feedback.created", + feedbackTriaged: "feedback.triaged", +} as const; + +export type DistributionEventType = (typeof DISTRIBUTION_EVENT_TYPES)[keyof typeof DISTRIBUTION_EVENT_TYPES]; + +/** Contracts schema id each distribution event payload mirrors. */ +export const DISTRIBUTION_EVENT_CONTRACT_SCHEMAS: Record = { + "release.published": "hasna.release.v1", + "release.rollout.started": "hasna.rollout_record.v1", + "release.rollout.completed": "hasna.rollout_record.v1", + "release.rollout.failed": "hasna.rollout_record.v1", + "app.installed": "hasna.rollout_record.v1", + "announcement.sent": "hasna.announcement.v1", + "feedback.created": "hasna.feedback.v1", + "feedback.triaged": "hasna.feedback.v1", +}; + +export type PublishPath = "skill" | "ci" | "backfilled"; +export type RolloutAction = "install" | "update" | "rollback" | "freeze-blocked"; + +/** Payload for `release.published`; mirrors `hasna.release.v1` key fields. */ +export type ReleasePublishedData = { + appId: string; + package: string; + version: string; + gitSha?: string; + publishedAt?: string; + publishPath?: PublishPath; + changelogRef?: string; + [key: string]: unknown; +}; + +/** Payload for `release.rollout.*`; mirrors `hasna.rollout_record.v1` key fields. */ +export type RolloutData = { + appId: string; + package: string; + version: string; + machine: string; + action?: RolloutAction; + result?: string; + error?: string; + [key: string]: unknown; +}; + +/** Payload for `app.installed`; mirrors `hasna.rollout_record.v1` (action install). */ +export type AppInstalledData = { + appId: string; + package: string; + version: string; + machine: string; + [key: string]: unknown; +}; + +/** Payload for `announcement.sent`; mirrors `hasna.announcement.v1` key fields. */ +export type AnnouncementSentData = { + campaignId: string; + appId?: string; + audienceId?: string; + releaseId?: string; + channels?: string[]; + [key: string]: unknown; +}; + +/** Payload for `feedback.created`. */ +export type FeedbackCreatedData = { + feedbackId: string; + appId?: string; + source?: string; + summary?: string; + severity?: string; + [key: string]: unknown; +}; + +/** Payload for `feedback.triaged`. */ +export type FeedbackTriagedData = { + feedbackId: string; + disposition: string; + appId?: string; + triagedBy?: string; + [key: string]: unknown; +}; + +export type DistributionEventDataMap = { + "release.published": ReleasePublishedData; + "release.rollout.started": RolloutData; + "release.rollout.completed": RolloutData; + "release.rollout.failed": RolloutData; + "app.installed": AppInstalledData; + "announcement.sent": AnnouncementSentData; + "feedback.created": FeedbackCreatedData; + "feedback.triaged": FeedbackTriagedData; +}; + +// --------------------------------------------------------------------------- +// Structural validators (dependency-free) +// --------------------------------------------------------------------------- + +const PUBLISH_PATHS: readonly string[] = ["skill", "ci", "backfilled"]; +const ROLLOUT_ACTIONS: readonly string[] = ["install", "update", "rollback", "freeze-blocked"]; + +function requireString(data: EventData, key: string, issues: EventValidationIssue[]): void { + const value = data[key]; + if (typeof value !== "string" || value.trim().length === 0) { + issues.push({ path: key, message: "must be a non-empty string" }); + } +} + +function optionalString(data: EventData, key: string, issues: EventValidationIssue[]): void { + const value = data[key]; + if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) { + issues.push({ path: key, message: "must be a non-empty string when present" }); + } +} + +function optionalEnum(data: EventData, key: string, allowed: readonly string[], issues: EventValidationIssue[]): void { + const value = data[key]; + if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) { + issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` }); + } +} + +function optionalStringArray(data: EventData, key: string, issues: EventValidationIssue[]): void { + const value = data[key]; + if (value === undefined) return; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) { + issues.push({ path: key, message: "must be an array of non-empty strings when present" }); + } +} + +function toResult(issues: EventValidationIssue[]): EventValidationResult { + return issues.length === 0 ? { ok: true } : { ok: false, issues }; +} + +export const validateReleasePublishedData: EventDataValidator = (data) => { + const issues: EventValidationIssue[] = []; + requireString(data, "appId", issues); + requireString(data, "package", issues); + requireString(data, "version", issues); + optionalString(data, "gitSha", issues); + optionalString(data, "publishedAt", issues); + optionalEnum(data, "publishPath", PUBLISH_PATHS, issues); + return toResult(issues); +}; + +export const validateRolloutData: EventDataValidator = (data, event) => { + const issues: EventValidationIssue[] = []; + requireString(data, "appId", issues); + requireString(data, "package", issues); + requireString(data, "version", issues); + requireString(data, "machine", issues); + optionalEnum(data, "action", ROLLOUT_ACTIONS, issues); + if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") { + requireString(data, "result", issues); + } + return toResult(issues); +}; + +export const validateAppInstalledData: EventDataValidator = (data) => { + const issues: EventValidationIssue[] = []; + requireString(data, "appId", issues); + requireString(data, "package", issues); + requireString(data, "version", issues); + requireString(data, "machine", issues); + return toResult(issues); +}; + +export const validateAnnouncementSentData: EventDataValidator = (data) => { + const issues: EventValidationIssue[] = []; + requireString(data, "campaignId", issues); + optionalString(data, "appId", issues); + optionalString(data, "audienceId", issues); + optionalString(data, "releaseId", issues); + optionalStringArray(data, "channels", issues); + return toResult(issues); +}; + +export const validateFeedbackCreatedData: EventDataValidator = (data) => { + const issues: EventValidationIssue[] = []; + requireString(data, "feedbackId", issues); + optionalString(data, "appId", issues); + optionalString(data, "source", issues); + optionalString(data, "summary", issues); + return toResult(issues); +}; + +export const validateFeedbackTriagedData: EventDataValidator = (data) => { + const issues: EventValidationIssue[] = []; + requireString(data, "feedbackId", issues); + requireString(data, "disposition", issues); + optionalString(data, "appId", issues); + optionalString(data, "triagedBy", issues); + return toResult(issues); +}; + +/** Fresh definitions for every distribution event type. */ +export function createDistributionEventDefinitions(): EventTypeDefinition[] { + const bind = (type: DistributionEventType, validate: EventDataValidator, description: string): EventTypeDefinition => ({ + type, + contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type], + description, + validate, + }); + return [ + bind("release.published", validateReleasePublishedData, "A package version was published"), + bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"), + bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"), + bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"), + bind("app.installed", validateAppInstalledData, "An app was installed on a machine"), + bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"), + bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"), + bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged"), + ]; +} + +/** + * Register the distribution event types on a catalog (the shared default + * catalog when omitted). Opt-in: nothing is registered until this is called. + */ +export function registerDistributionEventTypes(catalog: EventTypeCatalog = defaultEventTypeCatalog): EventTypeCatalog { + for (const definition of createDistributionEventDefinitions()) { + catalog.register(definition); + } + return catalog; +} diff --git a/src/index.ts b/src/index.ts index fcc1b0f..bcd6ef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,17 +19,31 @@ import type { import { channelMatchesEvent } from "./filter.js"; import { decodeLocalJsonEventCursor, encodeLocalJsonEventCursor, JsonEventsStore, normalizeEventPageLimit, type EventsStore } from "./storage.js"; import { createDeliveryResult, dispatchChannel, type TransportDispatchOptions } from "./transports.js"; +import { defaultEventTypeCatalog, type EventTypeCatalog } from "./catalog.js"; export * from "./types.js"; export * from "./storage.js"; export * from "./filter.js"; export * from "./signing.js"; export * from "./transports.js"; +export * from "./catalog.js"; export interface EventsClientOptions extends TransportDispatchOptions { store?: EventsStore; dataDir?: string; redactors?: EventRedactor[]; + /** + * Event type catalog used by the opt-in emit-time validator hook. Defaults + * to the shared `defaultEventTypeCatalog`. + */ + catalog?: EventTypeCatalog; + /** + * Opt-in: when true, emitted events whose `type` is registered in the + * catalog are validated and rejected (with `EventValidationError`) before + * they are stored or delivered. Unregistered/free-form types always pass. + * Defaults to false, so existing emitters are untouched. + */ + validateCatalogTypes?: boolean; } export interface ChannelMatchResult { @@ -64,11 +78,15 @@ export class EventsClient { private store: EventsStore; private redactors: EventRedactor[]; private transportOptions: TransportDispatchOptions; + private catalog: EventTypeCatalog; + private validateCatalogTypes: boolean; constructor(options: EventsClientOptions = {}) { this.store = options.store ?? new JsonEventsStore(options.dataDir); this.redactors = options.redactors ?? []; this.transportOptions = { fetchImpl: options.fetchImpl }; + this.catalog = options.catalog ?? defaultEventTypeCatalog; + this.validateCatalogTypes = options.validateCatalogTypes ?? false; } async addChannel(input: Omit & Partial>): Promise { @@ -92,6 +110,12 @@ export class EventsClient { const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input)); + if (options.validate ?? this.validateCatalogTypes) { + // Opt-in hook: throws EventValidationError for registered types with an + // invalid payload BEFORE the event is stored or delivered. + // Unregistered/free-form types always pass. + this.catalog.assertEventValid(event); + } const append = await this.appendEvent(event, { dedupe: options.dedupe !== false }); if (append.deduped) { return { event: append.event as EventEnvelope, deliveries: [], deduped: true }; diff --git a/src/types.ts b/src/types.ts index 3c456ca..4fca46f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -123,6 +123,14 @@ export interface EmitOptions { deliver?: boolean; dedupe?: boolean; redactSensitiveData?: boolean; + /** + * Per-emit override for the opt-in catalog validator hook. When true the + * event is validated against the client's `EventTypeCatalog` before it is + * stored or delivered (types not registered in the catalog always pass); + * when false validation is skipped even if the client enabled + * `validateCatalogTypes`. Defaults to the client-level setting (off). + */ + validate?: boolean; } export interface EventPageOptions { From 38fdb78ab901c231e00f69974cef65ac17b82b0e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 15:52:33 +0300 Subject: [PATCH 2/2] docs: fix catalog validation example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3994cf8..c3bd688 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ const client = new EventsClient({ catalog, validateCatalogTypes: true }); await client.emit({ source: "open-publish", type: "release.published", - data: { appId: "open-todos", package: "@hasna/todos", version: "0.11.63" }, + data: { appId: "open-todos" }, }); ```