From a342d064ad7fc3d678848a50fcbcdba491c431bd Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sat, 9 May 2026 13:52:38 +0200 Subject: [PATCH] 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 --- .../core-package/events/AGENTS.md.hbs | 9 ++++ .../core-package/events/eslint.config.js.hbs | 3 ++ .../core-package/events/package.json.hbs | 32 ++++++++++++ .../events/src/event-bus.interface.ts.hbs | 24 +++++++++ .../events/src/event-descriptor.test.ts.hbs | 24 +++++++++ .../events/src/event-descriptor.ts.hbs | 13 +++++ .../src/in-memory-event-bus.test.ts.hbs | 49 ++++++++++++++++++ .../events/src/in-memory-event-bus.ts.hbs | 42 +++++++++++++++ .../core-package/events/src/index.ts.hbs | 6 +++ .../src/payload-jobs-event-bus.test.ts.hbs | 51 +++++++++++++++++++ .../events/src/payload-jobs-event-bus.ts.hbs | 43 ++++++++++++++++ .../core-package/events/src/symbols.ts.hbs | 3 ++ .../core-package/events/tsconfig.json.hbs | 12 +++++ .../core-package/events/turbo.json.hbs | 4 ++ .../core-package/events/vitest.config.ts.hbs | 9 ++++ 15 files changed, 324 insertions(+) create mode 100644 turbo/generators/templates/core-package/events/AGENTS.md.hbs create mode 100644 turbo/generators/templates/core-package/events/eslint.config.js.hbs create mode 100644 turbo/generators/templates/core-package/events/package.json.hbs create mode 100644 turbo/generators/templates/core-package/events/src/event-bus.interface.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/event-descriptor.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/event-descriptor.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/in-memory-event-bus.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/in-memory-event-bus.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/index.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.test.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/src/symbols.ts.hbs create mode 100644 turbo/generators/templates/core-package/events/tsconfig.json.hbs create mode 100644 turbo/generators/templates/core-package/events/turbo.json.hbs create mode 100644 turbo/generators/templates/core-package/events/vitest.config.ts.hbs diff --git a/turbo/generators/templates/core-package/events/AGENTS.md.hbs b/turbo/generators/templates/core-package/events/AGENTS.md.hbs new file mode 100644 index 0000000..4ea8fe0 --- /dev/null +++ b/turbo/generators/templates/core-package/events/AGENTS.md.hbs @@ -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`. diff --git a/turbo/generators/templates/core-package/events/eslint.config.js.hbs b/turbo/generators/templates/core-package/events/eslint.config.js.hbs new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/turbo/generators/templates/core-package/events/eslint.config.js.hbs @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/turbo/generators/templates/core-package/events/package.json.hbs b/turbo/generators/templates/core-package/events/package.json.hbs new file mode 100644 index 0000000..31c4be8 --- /dev/null +++ b/turbo/generators/templates/core-package/events/package.json.hbs @@ -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" + } +} diff --git a/turbo/generators/templates/core-package/events/src/event-bus.interface.ts.hbs b/turbo/generators/templates/core-package/events/src/event-bus.interface.ts.hbs new file mode 100644 index 0000000..abe70df --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/event-bus.interface.ts.hbs @@ -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 = (event: T) => Promise; + +export interface IEventBus extends EventBusProtocol { + publish( + descriptor: EventDescriptor>, + payload: T, + ): Promise; + + /** + * 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..`). + */ + subscribe( + descriptor: EventDescriptor>, + consumerFeature: string, + handler: EventHandler, + ): void; +} diff --git a/turbo/generators/templates/core-package/events/src/event-descriptor.test.ts.hbs b/turbo/generators/templates/core-package/events/src/event-descriptor.test.ts.hbs new file mode 100644 index 0000000..1dfc7ed --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/event-descriptor.test.ts.hbs @@ -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(); + }); +}); diff --git a/turbo/generators/templates/core-package/events/src/event-descriptor.ts.hbs b/turbo/generators/templates/core-package/events/src/event-descriptor.ts.hbs new file mode 100644 index 0000000..56cd3c7 --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/event-descriptor.ts.hbs @@ -0,0 +1,13 @@ +import type { z } from "zod"; + +export type EventDescriptor = { + readonly name: TName; + readonly schema: TSchema; +}; + +export function defineEvent( + name: TName, + schema: TSchema, +): EventDescriptor { + return { name, schema }; +} diff --git a/turbo/generators/templates/core-package/events/src/in-memory-event-bus.test.ts.hbs b/turbo/generators/templates/core-package/events/src/in-memory-event-bus.test.ts.hbs new file mode 100644 index 0000000..1ce7f4f --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/in-memory-event-bus.test.ts.hbs @@ -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(); + }); +}); diff --git a/turbo/generators/templates/core-package/events/src/in-memory-event-bus.ts.hbs b/turbo/generators/templates/core-package/events/src/in-memory-event-bus.ts.hbs new file mode 100644 index 0000000..6ac36e8 --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/in-memory-event-bus.ts.hbs @@ -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[]>(); + + constructor(private readonly options: InMemoryEventBusOptions = {}) {} + + async publish( + descriptor: EventDescriptor>, + payload: T, + ): Promise { + 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( + descriptor: EventDescriptor>, + _consumerFeature: string, + handler: EventHandler, + ): void { + const arr = this.handlers.get(descriptor.name) ?? []; + arr.push(handler as EventHandler); + this.handlers.set(descriptor.name, arr); + } +} diff --git a/turbo/generators/templates/core-package/events/src/index.ts.hbs b/turbo/generators/templates/core-package/events/src/index.ts.hbs new file mode 100644 index 0000000..d8e4acf --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/index.ts.hbs @@ -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"; diff --git a/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.test.ts.hbs b/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.test.ts.hbs new file mode 100644 index 0000000..87b5a06 --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.test.ts.hbs @@ -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..`", 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); + }); +}); diff --git a/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.ts.hbs b/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.ts.hbs new file mode 100644 index 0000000..20227bb --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/payload-jobs-event-bus.ts.hbs @@ -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..`. + * 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(); + + constructor(private readonly queue: IJobQueue) {} + + async publish( + descriptor: EventDescriptor>, + payload: T, + ): Promise { + 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( + descriptor: EventDescriptor>, + consumerFeature: string, + _handler: EventHandler, + ): void { + const arr = this.subscribers.get(descriptor.name) ?? []; + if (!arr.includes(consumerFeature)) arr.push(consumerFeature); + this.subscribers.set(descriptor.name, arr); + } +} diff --git a/turbo/generators/templates/core-package/events/src/symbols.ts.hbs b/turbo/generators/templates/core-package/events/src/symbols.ts.hbs new file mode 100644 index 0000000..10bb1a7 --- /dev/null +++ b/turbo/generators/templates/core-package/events/src/symbols.ts.hbs @@ -0,0 +1,3 @@ +export const CORE_EVENTS_SYMBOLS = { + IEventBus: Symbol.for("@repo/core-events/IEventBus"), +} as const; diff --git a/turbo/generators/templates/core-package/events/tsconfig.json.hbs b/turbo/generators/templates/core-package/events/tsconfig.json.hbs new file mode 100644 index 0000000..652e804 --- /dev/null +++ b/turbo/generators/templates/core-package/events/tsconfig.json.hbs @@ -0,0 +1,12 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo/generators/templates/core-package/events/turbo.json.hbs b/turbo/generators/templates/core-package/events/turbo.json.hbs new file mode 100644 index 0000000..dcb8fb3 --- /dev/null +++ b/turbo/generators/templates/core-package/events/turbo.json.hbs @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["core"] +} diff --git a/turbo/generators/templates/core-package/events/vitest.config.ts.hbs b/turbo/generators/templates/core-package/events/vitest.config.ts.hbs new file mode 100644 index 0000000..2ee07c1 --- /dev/null +++ b/turbo/generators/templates/core-package/events/vitest.config.ts.hbs @@ -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") }, + }, +});