feat(generators): capture core-events as verbatim template files

Mirror packages/core-events/** into turbo/generators/templates/core-package/events/**/*.hbs.
15 files total (6 top-level + 9 src). No Handlebars interpolation needed
since none of the source files contain {{ }} patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:52:38 +02:00
parent 2266429efd
commit a342d064ad
15 changed files with 324 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
# @repo/core-events
Owns the cross-feature event bus: `IEventBus`, `defineEvent`, and two implementations (`InMemoryEventBus`, `PayloadJobsEventBus`).
**Boundary tag:** core. May be imported by feature, core, core-composition, app. May import from core-shared, tooling.
**Public surface:** `IEventBus`, `EventDescriptor`, `defineEvent`, `EventHandler`, `CORE_EVENTS_SYMBOLS`, both implementations.
**See:** `docs/decisions/adr-015-events-and-jobs.md` (pending), `docs/guides/events-and-jobs.md` (pending), `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,32 @@
{
"name": "@repo/core-events",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"zod": "^3.23.0"
},
"peerDependencies": {
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": { "optional": true }
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,24 @@
import type { z } from "zod";
import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols";
import type { EventDescriptor } from "./event-descriptor";
export type EventHandler<T> = (event: T) => Promise<void>;
export interface IEventBus extends EventBusProtocol {
publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
/**
* Subscribe a handler. `consumerFeature` is the kebab-case name of the
* subscribing feature (e.g., "marketing-pages"). It is unused by
* InMemoryEventBus; PayloadJobsEventBus uses it to name the fan-out task
* slug deterministically (`__events.<event>.<consumerFeature>`).
*/
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
handler: EventHandler<T>,
): void;
}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
describe("defineEvent", () => {
it("returns a descriptor with name and schema", () => {
const schema = z.object({ id: z.string() }).strict();
const descriptor = defineEvent("test.thing.happened", schema);
expect(descriptor.name).toBe("test.thing.happened");
expect(descriptor.schema).toBe(schema);
});
it("descriptor.schema parses valid payloads", () => {
const schema = z.object({ id: z.string() }).strict();
const d = defineEvent("test.evt", schema);
expect(() => d.schema.parse({ id: "abc" })).not.toThrow();
});
it("descriptor.schema rejects invalid payloads", () => {
const schema = z.object({ id: z.string() }).strict();
const d = defineEvent("test.evt", schema);
expect(() => d.schema.parse({ id: 123 })).toThrow();
});
});

View File

@@ -0,0 +1,13 @@
import type { z } from "zod";
export type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
): EventDescriptor<TName, TSchema> {
return { name, schema };
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { InMemoryEventBus } from "@/in-memory-event-bus";
const evt = defineEvent("test.thing", z.object({ id: z.string() }).strict());
describe("InMemoryEventBus", () => {
it("validates the payload via the descriptor's schema before fanout", async () => {
const bus = new InMemoryEventBus();
const handler = vi.fn();
bus.subscribe(evt, "test-consumer", handler);
await expect(bus.publish(evt, { id: 123 } as unknown as { id: string })).rejects.toThrow();
expect(handler).not.toHaveBeenCalled();
});
it("delivers to all registered handlers in parallel", async () => {
const bus = new InMemoryEventBus();
const a = vi.fn();
const b = vi.fn();
bus.subscribe(evt, "consumer-a", a);
bus.subscribe(evt, "consumer-b", b);
await bus.publish(evt, { id: "x" });
expect(a).toHaveBeenCalledWith({ id: "x" });
expect(b).toHaveBeenCalledWith({ id: "x" });
});
it("swallows handler errors by default (publisher's publish does not throw)", async () => {
const bus = new InMemoryEventBus();
bus.subscribe(evt, "boom", async () => {
throw new Error("subscriber blew up");
});
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
});
it("rethrows the first handler error when failFast is true", async () => {
const bus = new InMemoryEventBus({ failFast: true });
bus.subscribe(evt, "first", async () => {
throw new Error("first failure");
});
bus.subscribe(evt, "second", vi.fn());
await expect(bus.publish(evt, { id: "x" })).rejects.toThrow("first failure");
});
it("delivers nothing when no handlers are registered", async () => {
const bus = new InMemoryEventBus();
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,42 @@
import type { z } from "zod";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";
export type InMemoryEventBusOptions = {
/** When true, rethrow the first handler error (default: false — errors swallowed). */
failFast?: boolean;
};
export class InMemoryEventBus implements IEventBus {
private readonly handlers = new Map<string, EventHandler<unknown>[]>();
constructor(private readonly options: InMemoryEventBusOptions = {}) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const subscribers = this.handlers.get(descriptor.name) ?? [];
if (subscribers.length === 0) return;
const settled = await Promise.allSettled(
subscribers.map((h) => h(payload)),
);
if (this.options.failFast) {
const failure = settled.find((s) => s.status === "rejected");
// Only the first rejection is rethrown. Other failures are intentionally
// dropped — `failFast` is a test-affordance, not a fault-tolerance design.
if (failure && failure.status === "rejected") throw failure.reason;
}
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
_consumerFeature: string,
handler: EventHandler<T>,
): void {
const arr = this.handlers.get(descriptor.name) ?? [];
arr.push(handler as EventHandler<unknown>);
this.handlers.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,6 @@
export type { EventDescriptor } from "./event-descriptor";
export { defineEvent } from "./event-descriptor";
export type { IEventBus, EventHandler } from "./event-bus.interface";
export { CORE_EVENTS_SYMBOLS } from "./symbols";
export { InMemoryEventBus, type InMemoryEventBusOptions } from "./in-memory-event-bus";
export { PayloadJobsEventBus } from "./payload-jobs-event-bus";

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineEvent } from "@/event-descriptor";
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
import type { IJobQueue } from "@repo/core-shared/jobs";
const evt = defineEvent("auth.user.signed-up", z.object({ userId: z.string() }).strict());
function recordingQueue(): IJobQueue & { enqueued: { taskSlug: string; input: unknown }[] } {
const enqueued: { taskSlug: string; input: unknown }[] = [];
const q: IJobQueue = {
async enqueue(taskSlug, input) {
enqueued.push({ taskSlug, input });
return { jobId: `recording-${enqueued.length}` };
},
};
return Object.assign(q, { enqueued });
}
describe("PayloadJobsEventBus", () => {
it("validates the payload before enqueueing", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
bus.subscribe(evt, "marketing-pages", vi.fn());
await expect(
bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
).rejects.toThrow();
expect(queue.enqueued).toHaveLength(0);
});
it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
bus.subscribe(evt, "marketing-pages", vi.fn());
bus.subscribe(evt, "blog", vi.fn());
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(2);
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
"__events.auth.user.signed-up.blog",
"__events.auth.user.signed-up.marketing-pages",
]);
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
});
it("enqueues nothing when no subscribers are registered", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(0);
});
});

View File

@@ -0,0 +1,43 @@
import type { z } from "zod";
import type { IJobQueue } from "@repo/core-shared/jobs";
import type { EventDescriptor } from "./event-descriptor";
import type { EventHandler, IEventBus } from "./event-bus.interface";
/**
* Production-grade bus: for each subscriber, enqueues one Payload task per
* `publish()` call. Subscribers register with their consumer-feature name so
* fan-out tasks are named deterministically: `__events.<event>.<consumer>`.
* The actual handler invocation happens inside Payload's job runner — see the
* matching task config generated by `gen event consume` (Task 39).
*/
export class PayloadJobsEventBus implements IEventBus {
private readonly subscribers = new Map<string, string[]>();
constructor(private readonly queue: IJobQueue) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const consumers = this.subscribers.get(descriptor.name) ?? [];
await Promise.all(
consumers.map((consumerFeature) =>
this.queue.enqueue(
`__events.${descriptor.name}.${consumerFeature}`,
payload,
),
),
);
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
_handler: EventHandler<T>,
): void {
const arr = this.subscribers.get(descriptor.name) ?? [];
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
this.subscribers.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,3 @@
export const CORE_EVENTS_SYMBOLS = {
IEventBus: Symbol.for("@repo/core-events/IEventBus"),
} as const;

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});