# Cross-feature events and background jobs — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship the cross-feature event bus and background-job conventions defined in `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`, including two new core packages, two ESLint rules, two Plop generators, recording test helpers, the existing-feature anchor retrofit, an end-to-end proof-of-life cross-feature flow, and the documentation set. **Architecture:** Two new abstractions — `IEventBus` in a new `@repo/core-events` package, `IJobQueue` in `@repo/core-shared/jobs` — each with an in-memory implementation (dev/test) and a Payload-jobs-backed implementation (prod). Selection follows the existing `bindAll()` rules in `apps/web-next/src/server/bind-production.ts`. Per-feature binders gain `bus, queue` parameters. Cross-feature reactions go publisher → bus → consumer handler → optional job; consumers import only the publisher's contract from the publisher's root export. Generators augment existing feature packages by inserting at six fixed `// ` anchor comments. **Tech Stack:** TypeScript, Zod, Inversify (DI), Vitest, ESLint flat config, Plop (via `@turbo/gen`), Payload 3.x (`payload.jobs` API for production transport), Turborepo, pnpm workspaces. **Plan length:** ~60 bite-sized tasks across 9 phases. Each task is 2–5 minutes of work and ends in a commit. --- ## Phase 0 — Read first Read these once before starting; they contain rules and patterns the rest of the plan assumes: - `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md` — the design this plan implements. - `AGENTS.md` § Per-Package Conventions and § Instrumentation conventions. - `CLAUDE.md` § Key Conventions. - `packages/auth/src/di/bind-production.ts` — canonical binder shape we extend. - `apps/web-next/src/server/bind-production.ts` — canonical `bindAll()` shape we extend. - `docs/decisions/adr-014-instrumentation-sentry.md` (R40, R41–R44) — the span+capture sandwich rules events/jobs reuse. - `docs/decisions/adr-013-input-output-unification.md` (R1–R5) — the schemas-in-use-case pattern jobs mirror. Conventions used throughout this plan: - **Spec sections** are referenced as `spec § N.N`. - **Verification commands** use `pnpm --filter typecheck test lint` for package-scoped checks; `pnpm typecheck` / `pnpm test` / `pnpm turbo boundaries` for whole-monorepo checks. - **Commit style** matches the repo's existing format (e.g., `feat(core-events): InMemoryEventBus`). Short imperative subject under 70 chars, optional body explaining the *why* if non-obvious. - **TDD order:** failing test → run to confirm fail → minimal implementation → run to confirm pass → commit. Skipped only for tasks that add no behavior (anchor comments, doc updates, signature-only edits with no logic change) — those use a make-the-change → verify-typecheck → commit sequence. --- ## File map (summary) **New files:** ``` packages/core-events/ # NEW package, tag: core package.json tsconfig.json vitest.config.ts eslint.config.js turbo.json AGENTS.md src/ event-descriptor.ts event-descriptor.test.ts event-bus.interface.ts in-memory-event-bus.ts in-memory-event-bus.test.ts payload-jobs-event-bus.ts payload-jobs-event-bus.test.ts symbols.ts index.ts packages/core-shared/src/jobs/ # NEW subdirectory + ./jobs subpath job-queue.interface.ts in-memory-job-queue.ts in-memory-job-queue.test.ts payload-job-queue.ts payload-job-queue.test.ts symbols.ts index.ts packages/core-testing/src/instrumentation/ # extends existing dir recording-event-bus.ts recording-event-bus.test.ts recording-job-queue.ts recording-job-queue.test.ts packages/core-eslint/src/ # extends existing dir no-handler-reexport.rule.ts no-handler-reexport.rule.test.ts no-direct-payload-jobs.rule.ts no-direct-payload-jobs.rule.test.ts turbo/generators/templates/ # extends existing dir event/publish/event.ts.hbs event/publish/event.test.ts.hbs event/consume/handler.ts.hbs event/consume/handler.test.ts.hbs event/consume/binder-block.ts.hbs # snippet for modify action event/consume/symbol.ts.hbs # snippet for modify action job/job.ts.hbs job/job.test.ts.hbs job/task.ts.hbs job/binder-block.ts.hbs # snippet for modify action job/symbol.ts.hbs # snippet for modify action job/cms-export.ts.hbs # snippet for modify action packages/marketing-pages/src/application/services/ # NEW for proof-of-life mailer.service.interface.ts packages/marketing-pages/src/infrastructure/services/ recording-mailer.service.ts # mock impl for the e2e test docs/decisions/adr-015-events-and-jobs.md # NEW docs/guides/events-and-jobs.md # NEW ``` **Modified files (anchor retrofit; pure additive comments):** For each of `auth`, `blog`, `media`, `marketing-pages`, `navigation`: ``` packages//src/index.ts # + // packages//src/di/symbols.ts # + // and // packages//src/di/bind-production.ts # + // and // packages//src/di/bind-dev-seed.ts # + same two anchors packages//src/integrations/cms/index.ts # + // ``` Plus the feature template under `turbo/generators/templates/feature/` gets the same anchor additions to its `.hbs` files (CMS index excluded — Phase-1 generator doesn't emit it). **Modified files (binder signatures + bindAll):** ``` packages//src/di/bind-production.ts # + bus, queue params packages//src/di/bind-dev-seed.ts # + bus, queue params packages//src/di/bind-production.test.ts # update test invocations packages//src/di/bind-dev-seed.test.ts # update test invocations apps/web-next/src/server/bind-production.ts # + resolveEventsAndJobs(), pass through apps/web-next/src/server/bind-production.test.ts # update assertions apps/cms/src/server/bind-production.ts # same as web-next (if file exists; verify in Phase 5) apps/web-tanstack/src/server/bind-production.ts # same (if exists; verify in Phase 5) turbo/generators/config.ts # + event + job generators turbo/generators/templates/feature/... # anchor inserts in template ``` **Modified files (docs):** ``` AGENTS.md # new section under Per-Package Conventions CLAUDE.md # Quick Start + Read First + Key Conventions docs/guides/scaffolding-a-feature.md # two new sections docs/architecture/vertical-feature-spec.md # update lines :260 and :662 ``` --- ## Phase 1 — Job queue in `core-shared` `IJobQueue` lives in `core-shared` (spec § 3.2 / § 6.4) so features can use jobs without depending on `core-events`. The bus depends on the queue, not vice versa. ### Task 1: Add `./jobs` subpath to `@repo/core-shared` package.json **Files:** - Modify: `packages/core-shared/package.json` - [ ] **Step 1: Read the current exports map** Read `packages/core-shared/package.json` to locate the `"exports"` block. - [ ] **Step 2: Add the `./jobs` subpath** Add this entry alongside the existing subpaths: ```json "./jobs": "./src/jobs/index.ts", ``` - [ ] **Step 3: Verify exports JSON is valid and pnpm picks it up** Run: `pnpm install --filter @repo/core-shared` Expected: clean install, no warning about malformed exports. - [ ] **Step 4: Commit** ```bash git add packages/core-shared/package.json git commit -m "feat(core-shared): add ./jobs subpath export" ``` ### Task 2: Define `IJobQueue` interface **Files:** - Create: `packages/core-shared/src/jobs/job-queue.interface.ts` - [ ] **Step 1: Create the interface file** ```ts // packages/core-shared/src/jobs/job-queue.interface.ts export interface IJobQueue { enqueue( taskSlug: string, input: T, options?: { runAt?: Date }, ): Promise<{ jobId: string }>; } ``` - [ ] **Step 2: Verify typecheck** Run: `pnpm --filter @repo/core-shared typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/jobs/job-queue.interface.ts git commit -m "feat(core-shared/jobs): IJobQueue interface" ``` ### Task 3: Add `CORE_SHARED_JOBS_SYMBOLS` **Files:** - Create: `packages/core-shared/src/jobs/symbols.ts` - [ ] **Step 1: Create the symbols file** ```ts // packages/core-shared/src/jobs/symbols.ts export const CORE_SHARED_JOBS_SYMBOLS = { IJobQueue: Symbol.for("@repo/core-shared/jobs/IJobQueue"), } as const; ``` - [ ] **Step 2: Verify typecheck** Run: `pnpm --filter @repo/core-shared typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/jobs/symbols.ts git commit -m "feat(core-shared/jobs): symbol registry" ``` ### Task 4: Write the failing test for `InMemoryJobQueue` **Files:** - Create: `packages/core-shared/src/jobs/in-memory-job-queue.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-shared/src/jobs/in-memory-job-queue.test.ts import { describe, it, expect, vi } from "vitest"; import { InMemoryJobQueue } from "@/jobs/in-memory-job-queue"; import type { IJobQueue } from "@/jobs/job-queue.interface"; describe("InMemoryJobQueue", () => { it("returns a synthetic jobId on enqueue", async () => { const handler = vi.fn(); const queue: IJobQueue = new InMemoryJobQueue({ "test.task": handler, }); const result = await queue.enqueue("test.task", { x: 1 }); expect(result.jobId).toMatch(/^in-memory-/); }); it("invokes the registered handler asynchronously with the input", async () => { const handler = vi.fn(); const queue = new InMemoryJobQueue({ "test.task": handler }); await queue.enqueue("test.task", { x: 42 }); // Microtask boundary: handler runs after enqueue resolves await new Promise((r) => setImmediate(r)); expect(handler).toHaveBeenCalledWith({ x: 42 }); }); it("throws if the task slug has no registered handler", async () => { const queue = new InMemoryJobQueue({}); await expect(queue.enqueue("missing.task", {})).rejects.toThrow( /no handler registered for task slug: missing\.task/, ); }); it("delays execution when runAt is in the future", async () => { vi.useFakeTimers(); try { const handler = vi.fn(); const queue = new InMemoryJobQueue({ "test.task": handler }); const future = new Date(Date.now() + 1000); await queue.enqueue("test.task", {}, { runAt: future }); expect(handler).not.toHaveBeenCalled(); vi.advanceTimersByTime(1000); await Promise.resolve(); expect(handler).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); }); ``` - [ ] **Step 2: Run the test and confirm it fails** Run: `pnpm --filter @repo/core-shared test -- in-memory-job-queue` Expected: FAIL — `InMemoryJobQueue` not exported. ### Task 5: Implement `InMemoryJobQueue` **Files:** - Create: `packages/core-shared/src/jobs/in-memory-job-queue.ts` - [ ] **Step 1: Write the implementation** ```ts // packages/core-shared/src/jobs/in-memory-job-queue.ts import type { IJobQueue } from "./job-queue.interface"; export type InMemoryHandler = (input: unknown) => Promise | void; export class InMemoryJobQueue implements IJobQueue { private counter = 0; constructor(private readonly handlers: Record) {} async enqueue( taskSlug: string, input: T, options?: { runAt?: Date }, ): Promise<{ jobId: string }> { const handler = this.handlers[taskSlug]; if (!handler) { throw new Error(`no handler registered for task slug: ${taskSlug}`); } this.counter += 1; const jobId = `in-memory-${this.counter}`; const delay = options?.runAt ? options.runAt.getTime() - Date.now() : 0; if (delay > 0) { setTimeout(() => void handler(input), delay); } else { setImmediate(() => void handler(input)); } return { jobId }; } } ``` - [ ] **Step 2: Run tests; expect PASS** Run: `pnpm --filter @repo/core-shared test -- in-memory-job-queue` Expected: 4 tests PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/jobs/in-memory-job-queue.ts packages/core-shared/src/jobs/in-memory-job-queue.test.ts git commit -m "feat(core-shared/jobs): InMemoryJobQueue" ``` ### Task 6: Write the failing test for `PayloadJobQueue` **Files:** - Create: `packages/core-shared/src/jobs/payload-job-queue.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-shared/src/jobs/payload-job-queue.test.ts import { describe, it, expect, vi } from "vitest"; import { PayloadJobQueue } from "@/jobs/payload-job-queue"; describe("PayloadJobQueue", () => { it("delegates to payload.jobs.queue with the task slug and input", async () => { const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-1" }); const payload = { jobs: { queue: queueMock } } as unknown as Parameters< typeof PayloadJobQueue.prototype.constructor >[0]; const queue = new PayloadJobQueue(payload); const result = await queue.enqueue("blog.republish", { id: 1 }); expect(queueMock).toHaveBeenCalledWith({ task: "blog.republish", input: { id: 1 }, waitUntil: undefined, }); expect(result).toEqual({ jobId: "payload-job-1" }); }); it("forwards runAt as waitUntil", async () => { const queueMock = vi.fn().mockResolvedValue({ id: "payload-job-2" }); const payload = { jobs: { queue: queueMock } } as never; const queue = new PayloadJobQueue(payload); const future = new Date(Date.now() + 5000); await queue.enqueue("blog.task", {}, { runAt: future }); expect(queueMock).toHaveBeenCalledWith( expect.objectContaining({ waitUntil: future }), ); }); }); ``` - [ ] **Step 2: Run; expect FAIL** Run: `pnpm --filter @repo/core-shared test -- payload-job-queue` Expected: FAIL — `PayloadJobQueue` not exported. ### Task 7: Implement `PayloadJobQueue` **Files:** - Create: `packages/core-shared/src/jobs/payload-job-queue.ts` - [ ] **Step 1: Write the implementation** ```ts // packages/core-shared/src/jobs/payload-job-queue.ts import type { Payload } from "payload"; import type { IJobQueue } from "./job-queue.interface"; /** * Production-grade queue: enqueues into Payload's built-in jobs system. * The wrapped Payload instance is constructed once at app boot via * `getPayload({ config })` and passed in. */ export class PayloadJobQueue implements IJobQueue { constructor(private readonly payload: Payload) {} async enqueue( taskSlug: string, input: T, options?: { runAt?: Date }, ): Promise<{ jobId: string }> { const result = await this.payload.jobs.queue({ task: taskSlug, input: input as never, waitUntil: options?.runAt, } as never); const jobId = (result as { id: string | number }).id; return { jobId: String(jobId) }; } } ``` > **Note on `as never` casts:** Payload's `jobs.queue` API uses generic types tied to the registered task registry. Since `PayloadJobQueue` is generic across all features, we accept the cost of a few `as never` casts at this boundary in exchange for a uniform `IJobQueue` interface. The boundary parse inside each job (spec § 5.5) recovers type safety at the actual handler entry. - [ ] **Step 2: Run tests; expect PASS** Run: `pnpm --filter @repo/core-shared test -- payload-job-queue` Expected: 2 tests PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/jobs/payload-job-queue.ts packages/core-shared/src/jobs/payload-job-queue.test.ts git commit -m "feat(core-shared/jobs): PayloadJobQueue" ``` ### Task 8: Create the `jobs/` barrel **Files:** - Create: `packages/core-shared/src/jobs/index.ts` - [ ] **Step 1: Write the barrel** ```ts // packages/core-shared/src/jobs/index.ts export type { IJobQueue } from "./job-queue.interface"; export { CORE_SHARED_JOBS_SYMBOLS } from "./symbols"; export { InMemoryJobQueue, type InMemoryHandler } from "./in-memory-job-queue"; export { PayloadJobQueue } from "./payload-job-queue"; ``` - [ ] **Step 2: Verify typecheck and that the subpath resolves** Run: `pnpm --filter @repo/core-shared typecheck && pnpm --filter @repo/core-shared test` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/jobs/index.ts git commit -m "feat(core-shared/jobs): public barrel via @repo/core-shared/jobs subpath" ``` --- ## Phase 2 — `@repo/core-events` package A new package, tag: `core` (spec § 3.1). ### Task 9: Scaffold the package skeleton **Files:** - Create: `packages/core-events/package.json` - Create: `packages/core-events/tsconfig.json` - Create: `packages/core-events/vitest.config.ts` - Create: `packages/core-events/eslint.config.js` - Create: `packages/core-events/turbo.json` - Create: `packages/core-events/AGENTS.md` - [ ] **Step 1: Read `packages/core-shared/package.json` and `packages/core-shared/tsconfig.json` as the reference shape** These are the closest analog (also tag: `core`). - [ ] **Step 2: Create `package.json`** ```json { "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-typescript": "workspace:*", "typescript": "^5.8.0", "vitest": "^3.0.0" } } ``` - [ ] **Step 3: Create `tsconfig.json`** ```json { "extends": "@repo/core-typescript/base.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 4: Create `vitest.config.ts`** ```ts import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", globals: true }, resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, }); ``` - [ ] **Step 5: Create `eslint.config.js`** Match the shape of `packages/core-shared/eslint.config.js` (read it first). It should use `@repo/core-eslint`'s base config. - [ ] **Step 6: Create `turbo.json`** Match the shape of `packages/core-shared/turbo.json` (read it first). Single-file config that extends the root with a build/test/lint pipeline. - [ ] **Step 7: Create `AGENTS.md`** ```markdown # @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`, `docs/guides/events-and-jobs.md`. ``` - [ ] **Step 8: Run `pnpm install` so the workspace picks up the new package** Run: `pnpm install` Expected: clean install, `@repo/core-events` registered. - [ ] **Step 9: Commit** ```bash git add packages/core-events git commit -m "chore(core-events): scaffold package" ``` ### Task 10: Add `core-events` to the boundary tag map **Files:** - Modify: `packages/core-eslint/` boundary config (file containing the tag map for `eslint-plugin-boundaries`) - Modify: root `turbo.json` (boundaries config) - [ ] **Step 1: Locate the boundary config** Run: `grep -rn "core-shared" packages/core-eslint/src/ turbo.json | head` Expected output should reveal where the tag map for `core` packages is configured. - [ ] **Step 2: Add `@repo/core-events` to the `core` tag** In every place that lists `core` packages by name (typically `packages/core-eslint/src/boundaries.ts` or similar plus `turbo.json`'s `boundaries.tags`), add `@repo/core-events` alongside `@repo/core-shared` and `@repo/core-ui`. - [ ] **Step 3: Verify the boundary check passes** Run: `pnpm turbo boundaries` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "chore(core-eslint,turbo): tag @repo/core-events as core" ``` ### Task 11: Define `EventDescriptor` + `defineEvent` **Files:** - Create: `packages/core-events/src/event-descriptor.ts` - Create: `packages/core-events/src/event-descriptor.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-events/src/event-descriptor.test.ts 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(); }); }); ``` - [ ] **Step 2: Run; expect FAIL** — `defineEvent` not exported. Run: `pnpm --filter @repo/core-events test` Expected: FAIL. - [ ] **Step 3: Implement** ```ts // packages/core-events/src/event-descriptor.ts 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 }; } ``` - [ ] **Step 4: Run; expect PASS** Run: `pnpm --filter @repo/core-events test` Expected: 3 tests PASS. - [ ] **Step 5: Commit** ```bash git add packages/core-events/src/event-descriptor.ts packages/core-events/src/event-descriptor.test.ts git commit -m "feat(core-events): EventDescriptor + defineEvent" ``` ### Task 12: Define `IEventBus` interface and symbols **Files:** - Create: `packages/core-events/src/event-bus.interface.ts` - Create: `packages/core-events/src/symbols.ts` - [ ] **Step 1: Create the interface** ```ts // packages/core-events/src/event-bus.interface.ts import type { z } from "zod"; import type { EventDescriptor } from "./event-descriptor"; export type EventHandler = (event: T) => Promise; export interface IEventBus { publish( descriptor: EventDescriptor>, payload: T, ): Promise; /** * Subscribe a handler. `consumerFeature` is the kebab-case name of the * subscribing feature (e.g., "marketing-pages"). InMemoryEventBus uses it * only as a debug tag; PayloadJobsEventBus uses it to name the fan-out task * slug deterministically (`__events..`). */ subscribe( descriptor: EventDescriptor>, consumerFeature: string, handler: EventHandler, ): void; } ``` - [ ] **Step 2: Create the symbols** ```ts // packages/core-events/src/symbols.ts export const CORE_EVENTS_SYMBOLS = { IEventBus: Symbol.for("@repo/core-events/IEventBus"), } as const; ``` - [ ] **Step 3: Verify typecheck** Run: `pnpm --filter @repo/core-events typecheck` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add packages/core-events/src/event-bus.interface.ts packages/core-events/src/symbols.ts git commit -m "feat(core-events): IEventBus interface + symbol registry" ``` ### Task 13: Write the failing test for `InMemoryEventBus` **Files:** - Create: `packages/core-events/src/in-memory-event-bus.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-events/src/in-memory-event-bus.test.ts 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(); }); }); ``` - [ ] **Step 2: Run; expect FAIL** — `InMemoryEventBus` not exported. Run: `pnpm --filter @repo/core-events test -- in-memory-event-bus` Expected: FAIL. ### Task 14: Implement `InMemoryEventBus` **Files:** - Create: `packages/core-events/src/in-memory-event-bus.ts` - [ ] **Step 1: Write the implementation** ```ts // packages/core-events/src/in-memory-event-bus.ts 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"); if (failure && failure.status === "rejected") throw failure.reason; } } subscribe( descriptor: EventDescriptor>, _consumerFeature: string, // tagging only; no behavior handler: EventHandler, ): void { const arr = this.handlers.get(descriptor.name) ?? []; arr.push(handler as EventHandler); this.handlers.set(descriptor.name, arr); } } ``` - [ ] **Step 2: Run tests; expect PASS** Run: `pnpm --filter @repo/core-events test -- in-memory-event-bus` Expected: 5 tests PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-events/src/in-memory-event-bus.ts packages/core-events/src/in-memory-event-bus.test.ts git commit -m "feat(core-events): InMemoryEventBus with failFast option" ``` ### Task 15: Write the failing test for `PayloadJobsEventBus` **Files:** - Create: `packages/core-events/src/payload-jobs-event-bus.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-events/src/payload-jobs-event-bus.test.ts 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); }); }); ``` - [ ] **Step 2: Run; expect FAIL** — `PayloadJobsEventBus` not exported. Run: `pnpm --filter @repo/core-events test -- payload-jobs-event-bus` Expected: FAIL. ### Task 16: Implement `PayloadJobsEventBus` **Files:** - Create: `packages/core-events/src/payload-jobs-event-bus.ts` - [ ] **Step 1: Write the implementation** ```ts // packages/core-events/src/payload-jobs-event-bus.ts 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. The subscribers are registered locally (their consumer * feature name is the second arg to `subscribe`) so this bus can name the * fan-out tasks deterministically. The actual handler invocation happens * inside Payload's job runner — see the consumer feature's bind-production * for the wiring that registers a corresponding TaskConfig with that slug. */ export class PayloadJobsEventBus implements IEventBus { private readonly subscribers = new Map(); // event name → consumer-feature names 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); } } ``` > **Implementation note:** `PayloadJobsEventBus.subscribe` doesn't actually invoke the consumer's handler — it stores the consumer feature name and, on `publish`, enqueues a Payload task at slug `__events..`. The matching Payload task config is generated by `gen event consume` (Task 40 — see the additional `add` action that emits an `__events--.task.ts` alongside the handler). The task config delegates to the consumer's container-resolved wrapped handler. With the generator + `core-cms` aggregation, the production bus is fully wired end-to-end. - [ ] **Step 2: Run tests; expect PASS** Run: `pnpm --filter @repo/core-events test -- payload-jobs-event-bus` Expected: 3 tests PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-events/src/payload-jobs-event-bus.ts packages/core-events/src/payload-jobs-event-bus.test.ts git commit -m "feat(core-events): PayloadJobsEventBus (fan-out via IJobQueue)" ``` ### Task 17: Create `core-events` public barrel **Files:** - Create: `packages/core-events/src/index.ts` - [ ] **Step 1: Write the barrel** ```ts // packages/core-events/src/index.ts 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"; ``` - [ ] **Step 2: Verify the package resolves** Run: `pnpm --filter @repo/core-events typecheck && pnpm --filter @repo/core-events test` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/core-events/src/index.ts git commit -m "feat(core-events): public barrel" ``` --- ## Phase 3 — Recording test helpers ### Task 18: Write the failing test for `RecordingJobQueue` **Files:** - Create: `packages/core-testing/src/instrumentation/recording-job-queue.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-testing/src/instrumentation/recording-job-queue.test.ts import { describe, it, expect } from "vitest"; import { RecordingJobQueue } from "@/instrumentation/recording-job-queue"; describe("RecordingJobQueue", () => { it("records every enqueue call", async () => { const queue = new RecordingJobQueue(); const future = new Date("2030-01-01"); await queue.enqueue("a.task", { x: 1 }); await queue.enqueue("b.task", { y: 2 }, { runAt: future }); expect(queue.enqueued).toEqual([ { taskSlug: "a.task", input: { x: 1 }, options: undefined }, { taskSlug: "b.task", input: { y: 2 }, options: { runAt: future } }, ]); }); it("returns a synthetic jobId per call", async () => { const queue = new RecordingJobQueue(); const a = await queue.enqueue("a", {}); const b = await queue.enqueue("b", {}); expect(a.jobId).toBe("recording-1"); expect(b.jobId).toBe("recording-2"); }); }); ``` - [ ] **Step 2: Run; expect FAIL** Run: `pnpm --filter @repo/core-testing test -- recording-job-queue` Expected: FAIL. ### Task 19: Implement `RecordingJobQueue` **Files:** - Create: `packages/core-testing/src/instrumentation/recording-job-queue.ts` - Modify: `packages/core-testing/src/instrumentation/index.ts` (export the new class) - [ ] **Step 1: Write the implementation** ```ts // packages/core-testing/src/instrumentation/recording-job-queue.ts import type { IJobQueue } from "@repo/core-shared/jobs"; export class RecordingJobQueue implements IJobQueue { readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = []; async enqueue( taskSlug: string, input: T, options?: { runAt?: Date }, ): Promise<{ jobId: string }> { this.enqueued.push({ taskSlug, input, options }); return { jobId: `recording-${this.enqueued.length}` }; } } ``` - [ ] **Step 2: Add `@repo/core-shared` to core-testing dependencies if not already present** Read `packages/core-testing/package.json`. If `@repo/core-shared` is not in `dependencies`, add it: `"@repo/core-shared": "workspace:*"`. Then `pnpm install`. - [ ] **Step 3: Re-export from the instrumentation barrel** Read `packages/core-testing/src/instrumentation/index.ts` and add: ```ts export { RecordingJobQueue } from "./recording-job-queue"; ``` - [ ] **Step 4: Run tests; expect PASS** Run: `pnpm --filter @repo/core-testing test -- recording-job-queue` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add packages/core-testing/src/instrumentation/recording-job-queue.ts packages/core-testing/src/instrumentation/recording-job-queue.test.ts packages/core-testing/src/instrumentation/index.ts packages/core-testing/package.json git commit -m "feat(core-testing): RecordingJobQueue" ``` ### Task 20: Write the failing test for `RecordingEventBus` **Files:** - Create: `packages/core-testing/src/instrumentation/recording-event-bus.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-testing/src/instrumentation/recording-event-bus.test.ts import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import { defineEvent } from "@repo/core-events"; import { RecordingEventBus } from "@/instrumentation/recording-event-bus"; const evt = defineEvent("test.evt", z.object({ id: z.string() }).strict()); describe("RecordingEventBus", () => { it("records every publish call after schema validation", async () => { const bus = new RecordingEventBus(); await bus.publish(evt, { id: "a" }); await bus.publish(evt, { id: "b" }); expect(bus.published).toEqual([ { name: "test.evt", payload: { id: "a" } }, { name: "test.evt", payload: { id: "b" } }, ]); }); it("rejects invalid payloads", async () => { const bus = new RecordingEventBus(); await expect( bus.publish(evt, { id: 1 } as unknown as { id: string }), ).rejects.toThrow(); expect(bus.published).toHaveLength(0); }); it("invokes registered handlers sequentially in subscription order", async () => { const bus = new RecordingEventBus(); const order: string[] = []; bus.subscribe(evt, "consumer-a", async () => { order.push("a"); }); bus.subscribe(evt, "consumer-b", async () => { order.push("b"); }); await bus.publish(evt, { id: "x" }); expect(order).toEqual(["a", "b"]); }); }); ``` - [ ] **Step 2: Run; expect FAIL** Run: `pnpm --filter @repo/core-testing test -- recording-event-bus` Expected: FAIL. ### Task 21: Implement `RecordingEventBus` **Files:** - Create: `packages/core-testing/src/instrumentation/recording-event-bus.ts` - Modify: `packages/core-testing/src/instrumentation/index.ts` - Modify: `packages/core-testing/package.json` (add `@repo/core-events`) - [ ] **Step 1: Add `@repo/core-events` to core-testing deps** In `packages/core-testing/package.json`, add `"@repo/core-events": "workspace:*"` to `dependencies`. Then `pnpm install`. - [ ] **Step 2: Write the implementation** ```ts // packages/core-testing/src/instrumentation/recording-event-bus.ts import type { z } from "zod"; import type { EventDescriptor, EventHandler, IEventBus } from "@repo/core-events"; export class RecordingEventBus implements IEventBus { readonly published: { name: string; payload: unknown }[] = []; private readonly handlers = new Map[]>(); async publish( descriptor: EventDescriptor>, payload: T, ): Promise { descriptor.schema.parse(payload); this.published.push({ name: descriptor.name, payload }); for (const h of this.handlers.get(descriptor.name) ?? []) await h(payload); } 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); } } ``` - [ ] **Step 3: Re-export from the barrel** Add to `packages/core-testing/src/instrumentation/index.ts`: ```ts export { RecordingEventBus } from "./recording-event-bus"; ``` - [ ] **Step 4: Run tests; expect PASS** Run: `pnpm --filter @repo/core-testing test -- recording-event-bus` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add packages/core-testing/src/instrumentation/recording-event-bus.ts packages/core-testing/src/instrumentation/recording-event-bus.test.ts packages/core-testing/src/instrumentation/index.ts packages/core-testing/package.json git commit -m "feat(core-testing): RecordingEventBus" ``` --- ## Phase 4 — ESLint rules ### Task 22: Write the failing test for `no-handler-reexport` **Files:** - Create: `packages/core-eslint/src/no-handler-reexport.rule.test.ts` - [ ] **Step 1: Find the existing rule-test pattern** Run: `find packages/core-eslint -name "*.rule.ts" -o -name "*.rule.test.ts" | head`. If no custom rule tests exist yet, check `packages/core-eslint`'s test setup conventions for the canonical `RuleTester` usage. - [ ] **Step 2: Write the test using `RuleTester`** ```ts // packages/core-eslint/src/no-handler-reexport.rule.test.ts import { RuleTester } from "@typescript-eslint/rule-tester"; import { describe } from "vitest"; import { rule } from "./no-handler-reexport.rule"; RuleTester.afterAll = () => {}; RuleTester.it = (text, fn) => fn(); RuleTester.itOnly = (text, fn) => fn(); RuleTester.describe = (text, fn) => fn(); const tester = new RuleTester(); describe("no-handler-reexport", () => { tester.run("no-handler-reexport", rule, { valid: [ { code: `export { foo } from "./foo";`, filename: "src/index.ts" }, { code: `export * from "./bar";`, filename: "src/index.ts" }, { code: `export { useCase } from "./application/sign-in.use-case";`, filename: "src/index.ts" }, ], invalid: [ { code: `export { onUserSignedUp } from "./events/handlers/on-auth-user-signed-up.handler";`, filename: "src/index.ts", errors: [{ messageId: "noHandlerReexport" }], }, { code: `export * from "./events/handlers/on-auth-user-signed-up.handler";`, filename: "src/index.ts", errors: [{ messageId: "noHandlerReexport" }], }, ], }); }); ``` - [ ] **Step 3: Run; expect FAIL** Run: `pnpm --filter @repo/core-eslint test -- no-handler-reexport` Expected: FAIL — rule not exported. ### Task 23: Implement `no-handler-reexport` **Files:** - Create: `packages/core-eslint/src/no-handler-reexport.rule.ts` - Modify: `packages/core-eslint/src/index.ts` or wherever rules are aggregated for distribution - Modify: the boundary config that adds rules to feature/core packages - [ ] **Step 1: Write the rule** ```ts // packages/core-eslint/src/no-handler-reexport.rule.ts import { ESLintUtils } from "@typescript-eslint/utils"; const HANDLER_PATTERN = /\/events\/handlers\/[^/]+\.handler(\.ts)?$/; export const rule = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", docs: { description: "Event handlers must not be re-exported (Rule E1)." }, messages: { noHandlerReexport: "Event handlers (`events/handlers/*.handler.ts`) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).", }, schema: [], }, defaultOptions: [], create(context) { return { ExportNamedDeclaration(node) { if (node.source && HANDLER_PATTERN.test(node.source.value)) { context.report({ node, messageId: "noHandlerReexport" }); } }, ExportAllDeclaration(node) { if (node.source && HANDLER_PATTERN.test(node.source.value)) { context.report({ node, messageId: "noHandlerReexport" }); } }, }; }, }); ``` - [ ] **Step 2: Wire the rule into the published config** Read the existing rule-aggregation file in `packages/core-eslint` (likely `src/index.ts` or `src/configs/feature.ts`). Register the new rule under the `@repo` namespace (or whatever the existing custom-rule namespace is). Add the rule to the feature flat-config preset so all feature packages enforce it. - [ ] **Step 3: Run rule tests; expect PASS** Run: `pnpm --filter @repo/core-eslint test -- no-handler-reexport` Expected: PASS. - [ ] **Step 4: Verify no existing feature trips it** Run: `pnpm lint` Expected: PASS — no current code violates the rule. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/src/no-handler-reexport.rule.ts packages/core-eslint/src/no-handler-reexport.rule.test.ts packages/core-eslint/src/index.ts git commit -m "feat(core-eslint): rule no-handler-reexport (Rule E1)" ``` ### Task 24: Write the failing test for `no-direct-payload-jobs` **Files:** - Create: `packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts` - [ ] **Step 1: Write the test** ```ts // packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts import { RuleTester } from "@typescript-eslint/rule-tester"; import { describe } from "vitest"; import { rule } from "./no-direct-payload-jobs.rule"; RuleTester.afterAll = () => {}; RuleTester.it = (text, fn) => fn(); RuleTester.itOnly = (text, fn) => fn(); RuleTester.describe = (text, fn) => fn(); const tester = new RuleTester(); describe("no-direct-payload-jobs", () => { tester.run("no-direct-payload-jobs", rule, { valid: [ // Allowed inside the integration layer { code: `await payload.jobs.queue({ task: "x", input: {} });`, filename: "packages/blog/src/integrations/cms/jobs/republish.task.ts", }, // Allowed inside core-shared/jobs { code: `await payload.jobs.queue({ task: "x", input: {} });`, filename: "packages/core-shared/src/jobs/payload-job-queue.ts", }, // Allowed: any non-jobs payload access { code: `await payload.find({ collection: "x" });`, filename: "packages/blog/src/infrastructure/repositories/articles.repository.ts", }, ], invalid: [ { code: `await payload.jobs.queue({ task: "x", input: {} });`, filename: "packages/blog/src/application/use-cases/x.use-case.ts", errors: [{ messageId: "noDirectPayloadJobs" }], }, { code: `await payload.jobs.queue({ task: "x", input: {} });`, filename: "packages/blog/src/infrastructure/repositories/articles.repository.ts", errors: [{ messageId: "noDirectPayloadJobs" }], }, ], }); }); ``` - [ ] **Step 2: Run; expect FAIL** Run: `pnpm --filter @repo/core-eslint test -- no-direct-payload-jobs` Expected: FAIL. ### Task 25: Implement `no-direct-payload-jobs` **Files:** - Create: `packages/core-eslint/src/no-direct-payload-jobs.rule.ts` - Modify: rule aggregation file - [ ] **Step 1: Write the rule** ```ts // packages/core-eslint/src/no-direct-payload-jobs.rule.ts import { ESLintUtils } from "@typescript-eslint/utils"; const ALLOW_PATTERNS = [ /\/integrations\/cms\/jobs\//, /\/core-shared\/src\/jobs\//, ]; export const rule = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", docs: { description: "Direct `payload.jobs.*` access is forbidden outside the integration layer. Use IJobQueue.", }, messages: { noDirectPayloadJobs: "`payload.jobs.*` is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in integrations/cms/jobs/** and core-shared/jobs/**.", }, schema: [], }, defaultOptions: [], create(context) { const filename = context.filename ?? context.getFilename(); if (ALLOW_PATTERNS.some((p) => p.test(filename))) return {}; return { MemberExpression(node) { if ( node.object.type === "MemberExpression" && node.object.object.type === "Identifier" && node.object.object.name === "payload" && node.object.property.type === "Identifier" && node.object.property.name === "jobs" ) { context.report({ node, messageId: "noDirectPayloadJobs" }); } }, }; }, }); ``` - [ ] **Step 2: Wire into the published config** Same rule-aggregation file as Task 23. Add to feature + repository presets. - [ ] **Step 3: Run rule tests; expect PASS** Run: `pnpm --filter @repo/core-eslint test -- no-direct-payload-jobs` Expected: PASS. - [ ] **Step 4: Verify no existing code trips it** Run: `pnpm lint` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/src/no-direct-payload-jobs.rule.ts packages/core-eslint/src/no-direct-payload-jobs.rule.test.ts packages/core-eslint/src/index.ts git commit -m "feat(core-eslint): rule no-direct-payload-jobs" ``` --- ## Phase 5 — Existing-feature anchor retrofit Add the six anchor comments to all five features. Each is a single-line comment that the generator will insert before. **Pure additive comments — no behavior change.** Spec § 9.1. ### Task 26: Add anchors to `auth` **Files:** - Modify: `packages/auth/src/index.ts` - Modify: `packages/auth/src/di/symbols.ts` - Modify: `packages/auth/src/di/bind-production.ts` - Modify: `packages/auth/src/di/bind-dev-seed.ts` - Modify: `packages/auth/src/integrations/cms/index.ts` (create file with anchor if missing) - [ ] **Step 1: Read each target file to find the right insertion point** For each file, the anchor goes near the bottom of the relevant section so generators insert at the natural append point. - [ ] **Step 2: Add `// ` to `src/index.ts`** At the end of the file (before the final EOF, after all existing exports): ```ts // ``` - [ ] **Step 3: Add anchors to `src/di/symbols.ts`** In the symbol registry block, after the last existing entry but before any closing brace: ```ts // // ``` The anchors live inside the `as const` symbol object — be careful to place them where TypeScript still accepts the syntax (between symbol entries, formatted as standalone comment lines). - [ ] **Step 4: Add anchors to `src/di/bind-production.ts`** Inside `bindProductionAuth(...)`, near the end of the function body but before the closing brace: ```ts // // ``` Place these AFTER the existing controller bindings — generators insert their blocks at the end of the binder. - [ ] **Step 5: Add the same two anchors to `src/di/bind-dev-seed.ts`** Same locations, near the end of the dev-seed binder function body. - [ ] **Step 6: Handle `src/integrations/cms/index.ts`** Run `ls packages/auth/src/integrations/cms/index.ts 2>/dev/null`. If the file exists, add at the end: ```ts // ``` If it does NOT exist, create it with just: ```ts // packages/auth/src/integrations/cms/index.ts // export {}; ``` - [ ] **Step 7: Verify nothing broke** Run: `pnpm --filter @repo/auth typecheck test lint` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add packages/auth/src/ git commit -m "chore(auth): add // anchor comments for event/job generators" ``` ### Task 27: Add anchors to `blog` Same shape as Task 26, applied to `packages/blog/src/`. - [ ] **Step 1: Apply the six anchors per Task 26 layout** Files: `src/index.ts`, `src/di/symbols.ts`, `src/di/bind-production.ts`, `src/di/bind-dev-seed.ts`, `src/integrations/cms/index.ts`. - [ ] **Step 2: Verify** Run: `pnpm --filter @repo/blog typecheck test lint` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/blog/src/ git commit -m "chore(blog): add // anchor comments for event/job generators" ``` ### Task 28: Add anchors to `media` Same shape as Task 26, applied to `packages/media/src/`. - [ ] **Step 1: Apply the six anchors** - [ ] **Step 2: Verify** — `pnpm --filter @repo/media typecheck test lint` - [ ] **Step 3: Commit** — `chore(media): add // anchor comments for event/job generators` ### Task 29: Add anchors to `marketing-pages` Same shape applied to `packages/marketing-pages/src/`. - [ ] **Step 1: Apply the six anchors** - [ ] **Step 2: Verify** — `pnpm --filter @repo/marketing-pages typecheck test lint` - [ ] **Step 3: Commit** — `chore(marketing-pages): add // anchor comments for event/job generators` ### Task 30: Add anchors to `navigation` Same shape applied to `packages/navigation/src/`. - [ ] **Step 1: Apply the six anchors** - [ ] **Step 2: Verify** — `pnpm --filter @repo/navigation typecheck test lint` - [ ] **Step 3: Commit** — `chore(navigation): add // anchor comments for event/job generators` ### Task 31: Add an anchor-presence CI guard **Files:** - Create: `tests/anchors.test.ts` (or add to an existing repo-level test file) - [ ] **Step 1: Determine where repo-level tests live** Run: `find . -maxdepth 3 -name "*.test.ts" -path "*/tests/*" | head` and inspect. - [ ] **Step 2: Write a guard test** ```ts // tests/anchors.test.ts (or appropriate location) import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; const FEATURES = ["auth", "blog", "media", "marketing-pages", "navigation"]; const ANCHORS = { "src/index.ts": ["// "], "src/di/symbols.ts": ["// ", "// "], "src/di/bind-production.ts": ["// ", "// "], "src/di/bind-dev-seed.ts": ["// ", "// "], "src/integrations/cms/index.ts": ["// "], }; describe("// anchor presence", () => { for (const feature of FEATURES) { for (const [relPath, anchors] of Object.entries(ANCHORS)) { it(`${feature}/${relPath} contains all required anchors`, () => { const path = join(__dirname, "..", "packages", feature, relPath); const content = readFileSync(path, "utf8"); for (const anchor of anchors) { expect(content).toContain(anchor); } }); } } }); ``` - [ ] **Step 3: Run; expect PASS** Run: `pnpm vitest run tests/anchors.test.ts` (adjust path as needed) Expected: 25 tests PASS (5 features × 5 file/anchor combinations). - [ ] **Step 4: Commit** ```bash git add tests/anchors.test.ts git commit -m "test: anchor-presence CI guard for // comments" ``` ### Task 32: Add anchors to the `feature` generator template **Files:** - Modify: `turbo/generators/templates/feature/src/index.ts.hbs` - Modify: `turbo/generators/templates/feature/src/di/symbols.ts.hbs` - Modify: `turbo/generators/templates/feature/src/di/bind-production.ts.hbs` - Modify: `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs` (The template does NOT include `integrations/cms/index.ts` — `gen feature` skips Payload CMS templates per its current Phase-1 scope, so the `` anchor is added by `gen job` if the user later adds CMS integration to a generated feature.) - [ ] **Step 1: Read each `.hbs` file to find the insertion points** - [ ] **Step 2: Add anchors at the same logical positions as Task 26** - [ ] **Step 3: Run the existing `gen feature` against a sandbox name to verify the template still renders** ```bash cd /tmp && cp -r ~/Documents/Projects/template-vertical /tmp/template-test && cd /tmp/template-test && pnpm install && pnpm turbo gen feature --args anchor-test AnchorTest anchor-tests && pnpm --filter @repo/anchor-test typecheck lint test ``` Expected: clean generation; package passes typecheck/lint/test. - [ ] **Step 4: Clean up the sandbox** ```bash rm -rf /tmp/template-test ``` - [ ] **Step 5: Commit** ```bash git add turbo/generators/templates/feature/ git commit -m "chore(turbo-gen): add // anchor comments to feature template" ``` --- ## Phase 6 — Per-feature binder signatures + bindAll() bus/queue swap ### Task 33: Extend `bindProduction` and `bindDevSeed` signatures **Files (per feature, applied to all 5):** - Modify: `packages//src/di/bind-production.ts` - Modify: `packages//src/di/bind-production.test.ts` - Modify: `packages//src/di/bind-dev-seed.ts` - Modify: `packages//src/di/bind-dev-seed.test.ts` **Per-feature task structure** — the steps below apply to each feature in order: `auth`, `blog`, `media`, `marketing-pages`, `navigation`. Either bundle into one task per feature (5 commits) or one task with 5 sub-batches. Bundle. - [ ] **Step 1: For `auth` — extend the binder signatures** Modify `packages/auth/src/di/bind-production.ts`: ```ts // imports — add at top import type { IEventBus } from "@repo/core-events"; import type { IJobQueue } from "@repo/core-shared/jobs"; // signature change export function bindProductionAuth( config: SanitizedConfig, tracer: ITracer, logger: ILogger, bus: IEventBus, queue: IJobQueue, ): void { // ... existing body unchanged ... } ``` The body does NOT yet use `bus` / `queue` — Phase 7 wires them when the proof-of-life event/handler/job land. For this phase, accept-and-forward is sufficient. - [ ] **Step 2: Same for `auth`'s `bind-dev-seed.ts`** ```ts export async function bindDevSeedAuth( tracer: ITracer, logger: ILogger, bus: IEventBus, queue: IJobQueue, ): Promise { // ... existing body unchanged ... } ``` - [ ] **Step 3: Update `auth`'s tests** Both `bind-production.test.ts` and `bind-dev-seed.test.ts` currently call the binders. Add a `RecordingEventBus` and `RecordingJobQueue` instantiation at the top of each test and pass them through: ```ts import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation"; // inside each test: const bus = new RecordingEventBus(); const queue = new RecordingJobQueue(); bindProductionAuth(config, tracer, logger, bus, queue); ``` Add `@repo/core-events` to `packages/auth/package.json`'s `devDependencies` if not already a dep. Add `@repo/core-shared/jobs` access (no separate install — the package is already a dep). - [ ] **Step 4: Verify auth typechecks/tests pass** Run: `pnpm --filter @repo/auth typecheck test lint` Expected: PASS. - [ ] **Step 5: Commit auth** ```bash git add packages/auth/src/di/ packages/auth/package.json git commit -m "feat(auth): bind binders accept (bus, queue) params" ``` - [ ] **Step 6: Repeat steps 1–5 for `blog`** Same edits applied to `packages/blog/src/di/bind-production.ts`, `bind-dev-seed.ts`, and their test files. Update `packages/blog/package.json` if needed. Commit: `feat(blog): bind binders accept (bus, queue) params` - [ ] **Step 7: Repeat for `media`** Commit: `feat(media): bind binders accept (bus, queue) params` - [ ] **Step 8: Repeat for `marketing-pages`** Commit: `feat(marketing-pages): bind binders accept (bus, queue) params` - [ ] **Step 9: Repeat for `navigation`** Commit: `feat(navigation): bind binders accept (bus, queue) params` ### Task 34: Add `resolveEventsAndJobs()` to `apps/web-next` bindAll **Files:** - Modify: `apps/web-next/src/server/bind-production.ts` - Modify: `apps/web-next/src/server/bind-production.test.ts` - Modify: `apps/web-next/package.json` (add `@repo/core-events` if not already a dep) - [ ] **Step 1: Add deps if missing** In `apps/web-next/package.json`, add `"@repo/core-events": "workspace:*"` to `dependencies` if absent. Run `pnpm install`. - [ ] **Step 2: Add `resolveEventsAndJobs()` to `bind-production.ts`** ```ts // inside apps/web-next/src/server/bind-production.ts import { InMemoryEventBus, PayloadJobsEventBus, type IEventBus, } from "@repo/core-events"; import { InMemoryJobQueue, PayloadJobQueue, type IJobQueue, } from "@repo/core-shared/jobs"; import { getPayload } from "payload"; let resolvedBus: IEventBus | null = null; let resolvedQueue: IJobQueue | null = null; async function resolveEventsAndJobsProduction(): Promise<{ bus: IEventBus; queue: IJobQueue }> { if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue }; const resolvedConfig = await config; const payload = await getPayload({ config: resolvedConfig }); const queue = new PayloadJobQueue(payload); const bus = new PayloadJobsEventBus(queue); resolvedBus = bus; resolvedQueue = queue; return { bus, queue }; } function resolveEventsAndJobsDevSeed(): { bus: IEventBus; queue: IJobQueue } { if (resolvedBus && resolvedQueue) return { bus: resolvedBus, queue: resolvedQueue }; // In-memory queue starts with no handlers; per-feature binders will register them. // We construct one empty queue per app-boot; the dev-seed binders mutate the // shared `handlers` map by re-creating the queue once they know their tasks. // For Phase 1 we accept that dev-seed jobs run in-process via the binders' // direct closure; the queue.enqueue path is exercised by tests using // RecordingJobQueue, which is sufficient validation. const queue = new InMemoryJobQueue({}); const bus = new InMemoryEventBus(); resolvedBus = bus; resolvedQueue = queue; return { bus, queue }; } ``` > **Implementation note:** `InMemoryJobQueue`'s constructor takes a static handler map — but features want to register handlers at bind time. Two options: > 1. Make `InMemoryJobQueue` mutable with a `register(slug, handler)` method (small API change in Phase 1). > 2. Defer handler registration to a per-feature mechanism and exercise the queue only via test recordings. > > **Choice for this plan:** go with option 1. Add `register(slug, handler)` to `InMemoryJobQueue` as a follow-up step inside this task (sub-step below). - [ ] **Step 3: Add `register` method to `InMemoryJobQueue`** In `packages/core-shared/src/jobs/in-memory-job-queue.ts`: ```ts // add inside class InMemoryJobQueue register(slug: string, handler: InMemoryHandler): void { this.handlers[slug] = handler; } ``` Constructor changes: ```ts constructor(handlers: Record = {}) { this.handlers = { ...handlers }; } private readonly handlers: Record; ``` Add a unit test in `in-memory-job-queue.test.ts`: ```ts it("register adds a handler that can be enqueued against", async () => { const queue = new InMemoryJobQueue(); const handler = vi.fn(); queue.register("late.task", handler); await queue.enqueue("late.task", { z: 1 }); await new Promise((r) => setImmediate(r)); expect(handler).toHaveBeenCalledWith({ z: 1 }); }); ``` Run tests: `pnpm --filter @repo/core-shared test -- in-memory-job-queue` — expect PASS. - [ ] **Step 4: Update `bindAllProduction` and `bindAllDevSeed` to pass through** ```ts export async function bindAllProduction(): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); const { bus, queue } = await resolveEventsAndJobsProduction(); const resolvedConfig = await config; bindProductionAuth(resolvedConfig, tracer, logger, bus, queue); bindProductionBlog(resolvedConfig, tracer, logger, bus, queue); bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue); bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue); bindProductionMedia(resolvedConfig, tracer, logger, bus, queue); } export async function bindAllDevSeed(): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); const { bus, queue } = resolveEventsAndJobsDevSeed(); await bindDevSeedAuth(tracer, logger, bus, queue); await bindDevSeedBlog(tracer, logger, bus, queue); await bindDevSeedMarketingPages(tracer, logger, bus, queue); await bindDevSeedNavigation(tracer, logger, bus, queue); await bindDevSeedMedia(tracer, logger, bus, queue); } ``` Update `__resetBindStateForTests`: ```ts export function __resetBindStateForTests(): void { bound = false; resolvedTracer = null; resolvedLogger = null; resolvedBus = null; resolvedQueue = null; } ``` - [ ] **Step 5: Update `bind-production.test.ts`** Read the existing test file. Update it to assert that `bindAllProduction` resolves the Payload-backed bus + queue and that `bindAllDevSeed` resolves the in-memory ones. Add a test asserting `bus` and `queue` are passed to each per-feature binder (use `vi.mock` on the per-feature binders, or read existing mocking patterns). - [ ] **Step 6: Verify** Run: `pnpm --filter @repo/web-next typecheck test lint` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add apps/web-next/ packages/core-shared/src/jobs/ git commit -m "feat(web-next): bindAll resolves IEventBus + IJobQueue per env (spec § 6.3)" ``` ### Task 35: Mirror Task 34 to other apps that have a bindAll **Files:** - Modify: `apps/cms/src/server/bind-production.ts` (if exists) - Modify: `apps/web-tanstack/src/server/bind-production.ts` (if exists) - [ ] **Step 1: Check for the file in each app** Run: `ls apps/cms/src/server/bind-production.ts apps/web-tanstack/src/server/bind-production.ts 2>/dev/null` - [ ] **Step 2: For each existing file, apply the same changes as Task 34** Use the same `resolveEventsAndJobs*` helpers. The CMS app uses Payload natively, so production mode for it will always wire `PayloadJobsEventBus` + `PayloadJobQueue`. - [ ] **Step 3: Verify each app** Run: `pnpm --filter @repo/cms typecheck test lint && pnpm --filter @repo/web-tanstack typecheck test lint` (skip filters for non-existent apps). - [ ] **Step 4: Commit** ```bash git add apps/cms/ apps/web-tanstack/ git commit -m "feat(cms,web-tanstack): bindAll resolves IEventBus + IJobQueue per env" ``` --- ## Phase 7 — Generators ### Task 36: Anchor-presence validation helper **Files:** - Create: `turbo/generators/lib/anchor-validate.ts` - [ ] **Step 1: Write the helper** ```ts // turbo/generators/lib/anchor-validate.ts import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; /** * Throws with a clear message if any anchor is missing from the target file. * Used in generator prompt validators to abort before writing partial output. */ export function assertAnchors( repoRoot: string, relativePath: string, anchors: string[], ): void { const path = join(repoRoot, relativePath); if (!existsSync(path)) { throw new Error(`Required file does not exist: ${relativePath}`); } const content = readFileSync(path, "utf8"); for (const anchor of anchors) { if (!content.includes(anchor)) { throw new Error( `Missing anchor "${anchor}" in ${relativePath}. Add it before running this generator.`, ); } } } ``` - [ ] **Step 2: No test in TDD style — exercised via the generator's own smoke runs** (Plop generators are notoriously hard to unit-test without a sandbox; we rely on the smoke tests in Tasks 38, 40, 42.) - [ ] **Step 3: Verify it compiles in the generators dir** Run: `cd turbo/generators && npx tsc --noEmit` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add turbo/generators/lib/anchor-validate.ts git commit -m "feat(turbo-gen): assertAnchors helper" ``` ### Task 37: Generator templates for `gen event publish` **Files:** - Create: `turbo/generators/templates/event/publish/event.ts.hbs` - Create: `turbo/generators/templates/event/publish/event.test.ts.hbs` - [ ] **Step 1: Write `event.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.ts import { z } from "zod"; import { defineEvent } from "@repo/core-events"; export const {{camelCase event}}EventSchema = z.object({}).strict(); export type {{pascalCase event}}Event = z.infer; export const {{camelCase event}}Event = defineEvent( "{{kebabCase feature}}.{{dotCase event}}", {{camelCase event}}EventSchema, ); ``` > **Plop helper note:** Plop ships kebab/camel/pascal helpers but not "dotCase". In Plop, register a custom helper or use `replace`. Easiest: have the prompt accept the dotted form directly (e.g. `user.signed-up`) and use `{{event}}` raw in the template for the wire name; use `{{kebabCase event}}` (which converts dots to hyphens) for the file name. Update template to use raw `{{event}}` for the descriptor name. Revised template: ```hbs // packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.ts import { z } from "zod"; import { defineEvent } from "@repo/core-events"; export const {{camelCase event}}EventSchema = z.object({}).strict(); export type {{pascalCase event}}Event = z.infer; export const {{camelCase event}}Event = defineEvent( "{{kebabCase feature}}.{{event}}", {{camelCase event}}EventSchema, ); ``` - [ ] **Step 2: Write `event.test.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/events/{{kebabCase event}}.event.test.ts import { describe, it, expect } from "vitest"; import { {{camelCase event}}EventSchema, {{camelCase event}}Event } from "@/events/{{kebabCase event}}.event"; describe("{{camelCase event}}Event", () => { it("has the correct wire name", () => { expect({{camelCase event}}Event.name).toBe("{{kebabCase feature}}.{{event}}"); }); it("validates an empty payload (stub schema)", () => { expect(() => {{camelCase event}}EventSchema.parse({})).not.toThrow(); }); }); ``` - [ ] **Step 3: Commit** ```bash git add turbo/generators/templates/event/publish/ git commit -m "feat(turbo-gen): templates for gen event publish" ``` ### Task 38: Wire `gen event publish` into `turbo/generators/config.ts` **Files:** - Modify: `turbo/generators/config.ts` - [ ] **Step 1: Read `config.ts` to understand the existing generator structure** The `feature` generator is the reference. We add a new `event` generator with a `mode` prompt that branches. - [ ] **Step 2: Add the `event` generator (publish branch)** Inside the `generator(plop)` function, after the existing `feature` generator's `setGenerator` call: ```ts plop.setGenerator("event", { description: "Scaffold an event contract (publish) or handler (consume)", prompts: [ { type: "list", name: "mode", message: "Mode:", choices: ["publish", "consume"], }, { type: "input", name: "feature", message: "Owning feature (kebab-case; for publish: contract package; for consume: consumer package):", validate(input: string) { if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; if (!existsSync(join(process.cwd(), "packages", input, "src"))) { return `packages/${input}/src does not exist`; } return true; }, }, { type: "input", name: "event", message: "Event slug (dotted-kebab past tense, e.g. 'user.signed-up'):", validate(input: string) { if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(input)) { return "Must be dotted-kebab past tense (e.g. user.signed-up)"; } return true; }, }, { type: "input", name: "publisher", message: "Publisher feature (kebab-case; only for consume mode):", when(answers: { mode: string }) { return answers.mode === "consume"; }, validate(input: string, answers: { event: string }) { if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; const eventKebab = answers.event.replace(/\./g, "-"); const path = join( process.cwd(), "packages", input, "src", "events", `${eventKebab}.event.ts`, ); if (!existsSync(path)) { return `Publisher contract not found: ${path}`; } return true; }, }, ], actions(answers) { const a = answers as { mode: "publish" | "consume"; feature: string; event: string; publisher?: string; }; if (a.mode === "publish") return publishActions(a); return consumeActions(a); }, }); ``` Add helpers `publishActions` and `consumeActions` and `existsSync`/`join` imports at the top of `config.ts`. - [ ] **Step 3: Implement `publishActions`** ```ts function publishActions(a: { feature: string; event: string }): PlopTypes.ActionType[] { const eventKebab = a.event.replace(/\./g, "-"); const indexPath = `packages/${a.feature}/src/index.ts`; return [ () => { assertAnchors(process.cwd(), indexPath, ["// "]); return `Anchors verified in ${indexPath}`; }, { type: "add", path: `packages/${a.feature}/src/events/${eventKebab}.event.ts`, templateFile: "templates/event/publish/event.ts.hbs", data: { event: a.event, feature: a.feature, eventKebab }, }, { type: "add", path: `packages/${a.feature}/src/events/${eventKebab}.event.test.ts`, templateFile: "templates/event/publish/event.test.ts.hbs", data: { event: a.event, feature: a.feature, eventKebab }, }, { type: "modify", path: indexPath, pattern: /\/\/ /, template: `// \nexport {\n {{camelCase event}}Event,\n {{camelCase event}}EventSchema,\n type {{pascalCase event}}Event,\n} from "./events/{{kebabCase event}}.event";`, data: { event: a.event }, }, () => printPublishNextSteps(a), ]; } function printPublishNextSteps(a: { feature: string; event: string }): string { return [ "", "─────────────────────────────────────────────────────────────", `Event ${a.feature}.${a.event} contract scaffolded.`, "", "Next steps (manual):", ` 1. Fill in the schema in packages/${a.feature}/src/events/${a.event.replace(/\./g, "-")}.event.ts`, ` 2. Pick a use case in packages/${a.feature}/src/application/use-cases/ that should publish.`, ` - Add 'bus: IEventBus' to the factory signature.`, ` - Call 'await bus.publish(${camelCase(a.event)}Event, payload)' after success.`, ` - Update the use case's DI binding to inject the bus.`, ` 3. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, "─────────────────────────────────────────────────────────────", "", ].join("\n"); } ``` (Define a small local `camelCase` helper at the bottom of `config.ts` if not already present — see how `feature` generator's `cap`/`camel` helpers are defined.) - [ ] **Step 4: Smoke-test the publish branch** Run from `/tmp` with a copy of the repo: ```bash cp -r ~/Documents/Projects/template-vertical /tmp/gen-test && cd /tmp/gen-test && pnpm install && pnpm turbo gen event --args publish auth user.signed-up && pnpm --filter @repo/auth typecheck test lint ``` Expected: clean generation; `packages/auth/src/events/user-signed-up.event.ts` and its test exist; `auth/src/index.ts` re-exports the contract; package passes typecheck/lint/test. - [ ] **Step 5: Clean up sandbox; commit** ```bash rm -rf /tmp/gen-test git add turbo/generators/config.ts git commit -m "feat(turbo-gen): event generator (publish mode)" ``` ### Task 39: Generator templates for `gen event consume` **Files:** - Create: `turbo/generators/templates/event/consume/handler.ts.hbs` - Create: `turbo/generators/templates/event/consume/handler.test.ts.hbs` - Create: `turbo/generators/templates/event/consume/event-task.ts.hbs` - [ ] **Step 1: Write `handler.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler.ts import type { {{pascalCase event}}Event } from "@repo/{{kebabCase publisher}}"; export type IOn{{pascalCase publisher}}{{pascalCase event}}Handler = ReturnType< typeof on{{pascalCase publisher}}{{pascalCase event}}Handler >; export const on{{pascalCase publisher}}{{pascalCase event}}Handler = () => async (_event: {{pascalCase event}}Event): Promise => { // TODO: implement the reaction. Inject dependencies via the factory's // constructor and use them here. The handler runs inside the consumer's // span+capture sandwich, so just throwing on failure is the right shape. }; ``` - [ ] **Step 2: Write `handler.test.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler.test.ts import { describe, it, expect } from "vitest"; import { on{{pascalCase publisher}}{{pascalCase event}}Handler } from "@/events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler"; describe("on{{pascalCase publisher}}{{pascalCase event}}Handler", () => { it("returns a function (factory shape)", () => { const handler = on{{pascalCase publisher}}{{pascalCase event}}Handler(); expect(typeof handler).toBe("function"); }); it("does not throw on a valid stub event", async () => { const handler = on{{pascalCase publisher}}{{pascalCase event}}Handler(); await expect(handler({} as never)).resolves.toBeUndefined(); }); }); ``` - [ ] **Step 3: Write `event-task.ts.hbs`** (Payload task that completes the production bus loop) ```hbs // packages/{{kebabCase feature}}/src/integrations/cms/jobs/__events-{{kebabCase publisher}}-{{kebabCase event}}.task.ts // Generated by `gen event consume`. The PayloadJobsEventBus enqueues this task // when {{kebabCase publisher}} publishes `{{kebabCase publisher}}.{{event}}`. The handler // resolves the consumer's wrapped event handler from the per-feature container // and invokes it with the typed payload. import type { TaskConfig } from "payload"; import { {{camelCase feature}}Container } from "../../../di/container"; import { {{constantCase feature}}_SYMBOLS } from "../../../di/symbols"; import type { IOn{{pascalCase publisher}}{{pascalCase event}}Handler } from "../../../events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler"; import type { {{pascalCase event}}Event } from "@repo/{{kebabCase publisher}}"; export const on{{pascalCase publisher}}{{pascalCase event}}EventTask: TaskConfig<"__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}"> = { slug: "__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}", inputSchema: [], retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } }, handler: async ({ input }) => { const handler = {{camelCase feature}}Container.get( {{constantCase feature}}_SYMBOLS.IOn{{pascalCase publisher}}{{pascalCase event}}Handler, ); await handler(input as {{pascalCase event}}Event); return { output: {} }; }, }; ``` - [ ] **Step 4: Commit** ```bash git add turbo/generators/templates/event/consume/ git commit -m "feat(turbo-gen): templates for gen event consume (handler + Payload task)" ``` ### Task 40: Wire `gen event consume` actions and modify-blocks **Files:** - Modify: `turbo/generators/config.ts` - [ ] **Step 1: Implement `consumeActions` in `config.ts`** ```ts function consumeActions(a: { feature: string; event: string; publisher: string; }): PlopTypes.ActionType[] { const eventKebab = a.event.replace(/\./g, "-"); const handlerName = `on${pascalCase(a.publisher)}${pascalCase(a.event)}`; const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`; return [ () => { assertAnchors(process.cwd(), symbolFile, ["// "]); assertAnchors(process.cwd(), bindProdFile, ["// "]); assertAnchors(process.cwd(), bindDevFile, ["// "]); assertAnchors(process.cwd(), cmsIndexFile, ["// "]); return "All required anchors present"; }, { type: "add", path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`, templateFile: "templates/event/consume/handler.ts.hbs", data: a, }, { type: "add", path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.test.ts`, templateFile: "templates/event/consume/handler.test.ts.hbs", data: a, }, // Production-bus task config — completes the PayloadJobsEventBus loop. // Slug must match what PayloadJobsEventBus.publish enqueues: // __events... { type: "add", path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`, templateFile: "templates/event/consume/event-task.ts.hbs", data: a, }, { type: "modify", path: symbolFile, pattern: /\/\/ /, template: `// \n I${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`, }, { type: "modify", path: bindProdFile, pattern: /\/\/ /, template: handlerBindBlock(a, "production"), }, { type: "modify", path: bindDevFile, pattern: /\/\/ /, template: handlerBindBlock(a, "dev-seed"), }, // Re-export the event-task config from integrations/cms/index.ts so // core-cms picks it up when aggregating Payload tasks. { type: "modify", path: cmsIndexFile, pattern: /\/\/ /, template: `// \nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`, }, () => printConsumeNextSteps(a), ]; } function handlerBindBlock( a: { feature: string; event: string; publisher: string }, _mode: "production" | "dev-seed", ): string { const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; const eventConst = `${camelCase(a.event)}Event`; const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`; const containerVar = `${camelCase(a.feature)}Container`; return `// // ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior. const ${wrappedVar} = withSpan( tracer, { name: "${a.feature}.${handlerFn}", op: "event-handler" }, withCapture( logger, { feature: "${a.feature}", layer: "event-handler", name: "${a.feature}.${handlerFn}", }, ${handlerFn}(), ), ); // Bind into the per-feature container so the production __events.* task // can resolve and invoke the wrapped handler. if (${containerVar}.isBound(${handlerSymbol})) { ${containerVar}.unbind(${handlerSymbol}); } ${containerVar}.bind(${handlerSymbol}).toConstantValue(${wrappedVar}); // Subscribe so the in-memory bus delivers in dev/test, and so the production // bus knows which consumers to fan out __events.* tasks for. bus.subscribe(${eventConst}, "${a.feature}", ${wrappedVar});`; } function printConsumeNextSteps(a: { feature: string; event: string; publisher: string }): string { return [ "", "─────────────────────────────────────────────────────────────", `Handler on-${a.publisher}-${a.event.replace(/\./g, "-")} scaffolded in ${a.feature}.`, "", "Next steps (manual):", ` 1. Implement the handler body in packages/${a.feature}/src/events/handlers/.`, ` 2. Add the import for ${camelCase(a.event)}Event in bind-production.ts and bind-dev-seed.ts (from @repo/${a.publisher}).`, ` 3. If your handler needs dependencies, add them to the factory signature and pass them in the generated bind block.`, ` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, "─────────────────────────────────────────────────────────────", "", ].join("\n"); } ``` > **Note:** The generated bind block does NOT include the import for `Event` from the publisher — that's a manual edit (printed in the next-steps). This is intentional: imports go at the top of the file, not at the anchor location, and Plop's `modify` is awkward for that. The user adds one import line. - [ ] **Step 2: Smoke-test consume mode** Use a sandbox copy. First run a publish from auth, then a consume in marketing-pages: ```bash cp -r ~/Documents/Projects/template-vertical /tmp/gen-test2 && cd /tmp/gen-test2 && pnpm install pnpm turbo gen event --args publish auth user.signed-up pnpm turbo gen event --args consume marketing-pages user.signed-up auth # manually edit marketing-pages/src/di/bind-production.ts to add the import for userSignedUpEvent pnpm --filter @repo/marketing-pages typecheck test lint ``` Expected: typecheck/lint/test pass after the manual import edit. - [ ] **Step 3: Clean up sandbox; commit** ```bash rm -rf /tmp/gen-test2 git add turbo/generators/config.ts git commit -m "feat(turbo-gen): event generator (consume mode)" ``` ### Task 41: Generator templates for `gen job` **Files:** - Create: `turbo/generators/templates/job/job.ts.hbs` - Create: `turbo/generators/templates/job/job.test.ts.hbs` - Create: `turbo/generators/templates/job/task.ts.hbs` - [ ] **Step 1: Write `job.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/jobs/{{kebabCase job}}.job.ts import { z } from "zod"; {{#if (eq inputShape "typed")}} export const {{camelCase job}}InputSchema = z .object({ exampleField: z.string() }) .strict(); {{else}} export const {{camelCase job}}InputSchema = z.object({}).strict(); {{/if}} export type {{pascalCase job}}Input = z.infer; export type I{{pascalCase job}}Job = ReturnType; export const {{camelCase job}}Job = () => async (input: {{pascalCase job}}Input): Promise => { {{camelCase job}}InputSchema.parse(input); // TODO: implement the job. Inject dependencies via the factory's constructor. }; ``` > **Plop helper note:** Plop ships `eq` via the inquirer integration; if it isn't registered, replace the conditional with two separate template files (e.g., `job.ts.void.hbs` and `job.ts.typed.hbs`) selected by `actions`. Test which works during smoke-testing. - [ ] **Step 2: Write `job.test.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/jobs/{{kebabCase job}}.job.test.ts import { describe, it, expect } from "vitest"; import { {{camelCase job}}Job, {{camelCase job}}InputSchema } from "@/jobs/{{kebabCase job}}.job"; describe("{{camelCase job}}Job", () => { it("validates input via the schema", async () => { const job = {{camelCase job}}Job(); await expect(job({ unexpectedField: 1 } as never)).rejects.toThrow(); }); it("accepts valid input", async () => { const job = {{camelCase job}}Job(); {{#if (eq inputShape "typed")}} await expect(job({ exampleField: "x" })).resolves.toBeUndefined(); {{else}} await expect(job({})).resolves.toBeUndefined(); {{/if}} }); }); ``` - [ ] **Step 3: Write `task.ts.hbs`** ```hbs // packages/{{kebabCase feature}}/src/integrations/cms/jobs/{{kebabCase job}}.task.ts import type { TaskConfig } from "payload"; import { {{camelCase feature}}Container } from "../../../di/container"; import { {{constantCase feature}}_SYMBOLS } from "../../../di/symbols"; import type { I{{pascalCase job}}Job } from "../../../jobs/{{kebabCase job}}.job"; export const {{camelCase job}}Task: TaskConfig<"{{kebabCase feature}}.{{kebabCase job}}"> = { slug: "{{kebabCase feature}}.{{kebabCase job}}", inputSchema: [], retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } }, handler: async ({ input }) => { const job = {{camelCase feature}}Container.get( {{constantCase feature}}_SYMBOLS.I{{pascalCase job}}Job, ); await job(input as never); return { output: {} }; }, }; ``` > The `handler` reads from the per-feature container directly (Inversify singleton). If a feature's container needs the Payload `req` for some other reason, that's a per-feature edit; the default scaffolded shape is "container resolves the wrapped job." - [ ] **Step 4: Commit** ```bash git add turbo/generators/templates/job/ git commit -m "feat(turbo-gen): templates for gen job" ``` ### Task 42: Wire `gen job` actions and modify-blocks **Files:** - Modify: `turbo/generators/config.ts` - [ ] **Step 1: Add the `job` generator** ```ts plop.setGenerator("job", { description: "Scaffold a background job in an existing feature", prompts: [ { type: "input", name: "feature", message: "Feature (kebab-case, must exist):", validate(input: string) { if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; if (!existsSync(join(process.cwd(), "packages", input, "src"))) { return `packages/${input}/src does not exist`; } return true; }, }, { type: "input", name: "job", message: "Job slug (verb-noun kebab, e.g. 'send-welcome-email'):", validate(input: string) { if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case"; return true; }, }, { type: "list", name: "inputShape", message: "Input shape:", choices: ["void", "typed"], default: "void", }, ], actions(answers) { const a = answers as { feature: string; job: string; inputShape: "void" | "typed" }; return jobActions(a); }, }); function jobActions(a: { feature: string; job: string; inputShape: "void" | "typed"; }): PlopTypes.ActionType[] { const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`; return [ () => { assertAnchors(process.cwd(), symbolFile, ["// "]); assertAnchors(process.cwd(), bindProdFile, ["// "]); assertAnchors(process.cwd(), bindDevFile, ["// "]); assertAnchors(process.cwd(), cmsIndexFile, ["// "]); return "All required anchors present"; }, { type: "add", path: `packages/${a.feature}/src/jobs/${a.job}.job.ts`, templateFile: "templates/job/job.ts.hbs", data: a, }, { type: "add", path: `packages/${a.feature}/src/jobs/${a.job}.job.test.ts`, templateFile: "templates/job/job.test.ts.hbs", data: a, }, { type: "add", path: `packages/${a.feature}/src/integrations/cms/jobs/${a.job}.task.ts`, templateFile: "templates/job/task.ts.hbs", data: a, }, { type: "modify", path: cmsIndexFile, pattern: /\/\/ /, template: `// \nexport { ${camelCase(a.job)}Task } from "./jobs/${a.job}.task";`, }, { type: "modify", path: symbolFile, pattern: /\/\/ /, template: `// \n I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camelCase(a.job)}Job"),`, }, { type: "modify", path: bindProdFile, pattern: /\/\/ /, template: jobBindBlock(a), }, { type: "modify", path: bindDevFile, pattern: /\/\/ /, template: jobBindBlock(a), }, () => printJobNextSteps(a), ]; } function jobBindBlock(a: { feature: string; job: string }): string { const factoryFn = `${camelCase(a.job)}Job`; const symbol = `${constantCase(a.feature)}_SYMBOLS.I${pascalCase(a.job)}Job`; return `// const wrapped${pascalCase(a.job)} = withSpan( tracer, { name: "${a.feature}.${camelCase(a.job)}", op: "job" }, withCapture( logger, { feature: "${a.feature}", layer: "job", name: "${a.feature}.${camelCase(a.job)}", }, ${factoryFn}(), ), ); if (${camelCase(a.feature)}Container.isBound(${symbol})) { ${camelCase(a.feature)}Container.unbind(${symbol}); } ${camelCase(a.feature)}Container.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`; } function printJobNextSteps(a: { feature: string; job: string }): string { return [ "", "─────────────────────────────────────────────────────────────", `Job ${a.feature}.${a.job} scaffolded.`, "", "Next steps (manual):", ` 1. Fill in the job body in packages/${a.feature}/src/jobs/${a.job}.job.ts`, ` 2. Add Payload field config to inputSchema in ${a.job}.task.ts (matches your Zod schema).`, ` 3. Add the import for ${camelCase(a.job)}Job in bind-production.ts and bind-dev-seed.ts.`, ` 4. (Optional) Add a cron schedule in core-cms's buildConfig if this job runs periodically.`, ` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, "─────────────────────────────────────────────────────────────", "", ].join("\n"); } ``` - [ ] **Step 2: Smoke-test the job generator** ```bash cp -r ~/Documents/Projects/template-vertical /tmp/gen-test3 && cd /tmp/gen-test3 && pnpm install pnpm turbo gen job --args marketing-pages send-welcome-email void # manually edit marketing-pages/src/di/bind-production.ts to add the import for sendWelcomeEmailJob pnpm --filter @repo/marketing-pages typecheck test lint ``` Expected: clean generation; typecheck/lint/test pass after the manual import edit. - [ ] **Step 3: Clean up; commit** ```bash rm -rf /tmp/gen-test3 git add turbo/generators/config.ts git commit -m "feat(turbo-gen): job generator" ``` --- ## Phase 8 — Proof-of-life: cross-feature flow (sign-up → welcome email) This validates that the full stack works end-to-end. Spec § 13. ### Task 43: Add `IMailerService` interface to `marketing-pages` **Files:** - Create: `packages/marketing-pages/src/application/services/mailer.service.interface.ts` - [ ] **Step 1: Write the interface** ```ts // packages/marketing-pages/src/application/services/mailer.service.interface.ts export interface IMailerService { sendWelcome(userId: string, email: string): Promise; } ``` - [ ] **Step 2: Add a symbol for it** In `packages/marketing-pages/src/di/symbols.ts`, add `IMailerService: Symbol.for("@repo/marketing-pages/IMailerService")` to the symbol object (NOT under any anchor — this is a regular service symbol). - [ ] **Step 3: Verify** Run: `pnpm --filter @repo/marketing-pages typecheck` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add packages/marketing-pages/src/application/services/mailer.service.interface.ts packages/marketing-pages/src/di/symbols.ts git commit -m "feat(marketing-pages): IMailerService interface (proof-of-life)" ``` ### Task 44: Add `RecordingMailerService` **Files:** - Create: `packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts` - Create: `packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts import { describe, it, expect } from "vitest"; import { RecordingMailerService } from "@/infrastructure/services/recording-mailer.service"; describe("RecordingMailerService", () => { it("records welcome calls", async () => { const mailer = new RecordingMailerService(); await mailer.sendWelcome("u1", "u1@example.com"); expect(mailer.sent).toEqual([{ userId: "u1", email: "u1@example.com" }]); }); }); ``` - [ ] **Step 2: Run; expect FAIL** Run: `pnpm --filter @repo/marketing-pages test -- recording-mailer` Expected: FAIL. - [ ] **Step 3: Implement** ```ts // packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts import type { IMailerService } from "../../application/services/mailer.service.interface"; export class RecordingMailerService implements IMailerService { readonly sent: { userId: string; email: string }[] = []; async sendWelcome(userId: string, email: string): Promise { this.sent.push({ userId, email }); } } ``` - [ ] **Step 4: Run; expect PASS** Run: `pnpm --filter @repo/marketing-pages test -- recording-mailer` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add packages/marketing-pages/src/infrastructure/services/recording-mailer.service.ts packages/marketing-pages/src/infrastructure/services/recording-mailer.service.test.ts git commit -m "feat(marketing-pages): RecordingMailerService" ``` ### Task 45: Use `gen event publish` to scaffold `auth.user.signed-up` - [ ] **Step 1: Run the generator** ```bash pnpm turbo gen event --args publish auth user.signed-up ``` Expected: clean generation; `packages/auth/src/events/user-signed-up.event.ts` exists; auth's `index.ts` re-exports it. - [ ] **Step 2: Fill in the schema** Edit `packages/auth/src/events/user-signed-up.event.ts`: ```ts export const userSignedUpEventSchema = z .object({ userId: z.string(), email: z.string().email(), signedUpAt: z.string().datetime(), }) .strict(); ``` - [ ] **Step 3: Update the generated test** Edit `packages/auth/src/events/user-signed-up.event.test.ts` to assert the actual schema rejects invalid input and accepts valid input. - [ ] **Step 4: Verify** Run: `pnpm --filter @repo/auth typecheck test lint` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add packages/auth/src/ git commit -m "feat(auth): userSignedUpEvent contract" ``` ### Task 46: Update `signUpUseCase` to publish **Files:** - Modify: `packages/auth/src/application/use-cases/sign-up.use-case.ts` - Modify: `packages/auth/src/application/use-cases/sign-up.use-case.test.ts` - [ ] **Step 1: Add a failing test asserting `bus.publish` is called** Read the existing test, then add: ```ts import { RecordingEventBus } from "@repo/core-testing/instrumentation"; import { userSignedUpEvent } from "../../events/user-signed-up.event"; it("publishes auth.user.signed-up after creating the user", async () => { const bus = new RecordingEventBus(); // ... existing mocks ... const useCase = signUpUseCase(mockUsers, mockAuth, bus); await useCase({ email: "a@b.com", password: "secret" }); expect(bus.published).toContainEqual( expect.objectContaining({ name: "auth.user.signed-up" }), ); }); ``` - [ ] **Step 2: Run; expect FAIL** — `signUpUseCase` doesn't take `bus` yet. - [ ] **Step 3: Update `signUpUseCase`** ```ts import type { IEventBus } from "@repo/core-events"; import { userSignedUpEvent } from "../../events/user-signed-up.event"; export const signUpUseCase = (users: IUsersRepository, auth: IAuthenticationService, bus: IEventBus) => async (input: SignUpInput): Promise => { // ... existing logic to create user + return token ... const user = /* existing creation */; await bus.publish(userSignedUpEvent, { userId: user.id, email: user.email, signedUpAt: new Date().toISOString(), }); return signUpOutputSchema.parse(/* existing return */); }; ``` - [ ] **Step 4: Update the binding in `bind-production.ts`** In `packages/auth/src/di/bind-production.ts`, the `signUpUseCase` factory call now takes `bus`: ```ts const wrappedSignUp = withSpan( tracer, { name: "auth.signUp", op: "use-case" }, withCapture( logger, { feature: "auth", layer: "use-case", name: "auth.signUp" }, signUpUseCase(repo, authService, bus), ), ); ``` Same for `bind-dev-seed.ts`. - [ ] **Step 5: Run; expect PASS** Run: `pnpm --filter @repo/auth typecheck test lint` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add packages/auth/src/ git commit -m "feat(auth): signUp publishes userSignedUpEvent" ``` ### Task 47: Use `gen event consume` to scaffold the marketing-pages handler - [ ] **Step 1: Run the generator** ```bash pnpm turbo gen event --args consume marketing-pages user.signed-up auth ``` Expected: clean generation. Files produced: - `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts` (+ .test.ts) - `packages/marketing-pages/src/integrations/cms/jobs/__events-auth-user-signed-up.task.ts` Modifications: - `symbols.ts` adds `IOnAuthUserSignedUpHandler`. - `bind-production.ts` / `bind-dev-seed.ts` get the wrapped-handler block + `bus.subscribe` call + container binding. - `integrations/cms/index.ts` re-exports the `__events-...task.ts` so `core-cms` aggregates it. - [ ] **Step 2: Add the missing imports to bind files** Both `packages/marketing-pages/src/di/bind-production.ts` and `bind-dev-seed.ts` need: ```ts import { userSignedUpEvent } from "@repo/auth"; import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler"; ``` - [ ] **Step 3: Verify the generated task exists and `core-cms` aggregates it** Run: `pnpm --filter @repo/marketing-pages typecheck lint` Run: `pnpm --filter @repo/core-cms typecheck` Expected: PASS for both. Open `packages/core-cms/src/index.ts` and confirm the import line for `onAuthUserSignedUpEventTask` is implicit (via `@repo/marketing-pages/cms`'s barrel re-export). If `core-cms` does NOT explicitly enumerate task names but instead spreads the imported `tasks` array, no edit is needed. If `core-cms` enumerates each task individually, add `onAuthUserSignedUpEventTask` to the `jobs.tasks` array — read `packages/core-cms/src/index.ts` to determine which. - [ ] **Step 4: Commit** ```bash git add packages/marketing-pages/src/ packages/core-cms/src/ git commit -m "feat(marketing-pages): subscribe to auth.user.signed-up (handler + production task)" ``` ### Task 48: Use `gen job` to scaffold `send-welcome-email` - [ ] **Step 1: Run the generator** ```bash pnpm turbo gen job --args marketing-pages send-welcome-email typed ``` - [ ] **Step 2: Fill in the schema and body** Edit `packages/marketing-pages/src/jobs/send-welcome-email.job.ts`: ```ts import { z } from "zod"; import type { IMailerService } from "../application/services/mailer.service.interface"; export const sendWelcomeEmailInputSchema = z .object({ userId: z.string(), email: z.string().email() }) .strict(); export type SendWelcomeEmailInput = z.infer; export type ISendWelcomeEmailJob = ReturnType; export const sendWelcomeEmailJob = (mailer: IMailerService) => async (input: SendWelcomeEmailInput): Promise => { sendWelcomeEmailInputSchema.parse(input); await mailer.sendWelcome(input.userId, input.email); }; ``` - [ ] **Step 3: Update the generated job test** ```ts import { RecordingMailerService } from "@/infrastructure/services/recording-mailer.service"; it("sends a welcome email via the mailer", async () => { const mailer = new RecordingMailerService(); const job = sendWelcomeEmailJob(mailer); await job({ userId: "u1", email: "u1@example.com" }); expect(mailer.sent).toEqual([{ userId: "u1", email: "u1@example.com" }]); }); ``` - [ ] **Step 4: Update the generated bind block to inject the mailer** The generator emits `sendWelcomeEmailJob()` (no args). Edit the bind blocks in both `bind-production.ts` and `bind-dev-seed.ts` to wire in a mailer: For `bind-dev-seed.ts`: ```ts const mailer = new RecordingMailerService(); marketingPagesContainer .bind(MARKETING_PAGES_SYMBOLS.IMailerService) .toConstantValue(mailer); // Then in the gen:jobs block: sendWelcomeEmailJob(mailer), ``` For `bind-production.ts`: bind a real mailer (placeholder: bind another `RecordingMailerService` until a real impl ships, or use a stub). - [ ] **Step 5: Update the handler to inject the queue and enqueue the job** In `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts`: ```ts import type { UserSignedUpEvent } from "@repo/auth"; import type { IJobQueue } from "@repo/core-shared/jobs"; export type IOnAuthUserSignedUpHandler = ReturnType; export const onAuthUserSignedUpHandler = (queue: IJobQueue) => async (event: UserSignedUpEvent): Promise => { await queue.enqueue("marketing-pages.send-welcome-email", { userId: event.userId, email: event.email, }); }; ``` Update the bind block to pass `queue`: ```ts ${handlerFn}(queue), // becomes: onAuthUserSignedUpHandler(queue), ``` (The generator emitted `onAuthUserSignedUpHandler()` with no args; you edit the bind block to pass `queue`.) - [ ] **Step 6: For dev-seed, register the job with the in-memory queue** In `bind-dev-seed.ts`, after binding the wrapped job: ```ts if (queue && typeof (queue as { register?: unknown }).register === "function") { (queue as InMemoryJobQueue).register( "marketing-pages.send-welcome-email", async (input) => { const wrapped = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, ); await wrapped(input as SendWelcomeEmailInput); }, ); } ``` This registration is what makes the in-memory queue actually fire when the handler enqueues. - [ ] **Step 7: Verify all marketing-pages tests pass** Run: `pnpm --filter @repo/marketing-pages typecheck test lint` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add packages/marketing-pages/src/ git commit -m "feat(marketing-pages): sendWelcomeEmail job + handler enqueues it" ``` ### Task 49: End-to-end test: sign-up triggers welcome email **Files:** - Create: `apps/web-next/tests/sign-up-welcome-email.test.ts` (or extend an existing e2e test file) - [ ] **Step 1: Determine where app-level tests live** Run: `find apps/web-next -name "*.test.ts" | head`. If there's no existing top-level test directory, place this one near the bind-production test. - [ ] **Step 2: Write the e2e test** ```ts // apps/web-next/tests/sign-up-welcome-email.test.ts import { describe, it, expect, beforeEach } from "vitest"; import { bindAllDevSeed, __resetBindStateForTests } from "@/server/bind-production"; import { authContainer } from "@repo/auth/di/container"; // adjust import path import { AUTH_SYMBOLS } from "@repo/auth/di/symbols"; import { marketingPagesContainer } from "@repo/marketing-pages/di/container"; import { MARKETING_PAGES_SYMBOLS } from "@repo/marketing-pages/di/symbols"; import type { ISignUpController } from "@repo/auth"; import type { IMailerService } from "@repo/marketing-pages"; import { RecordingMailerService } from "@repo/marketing-pages/src/infrastructure/services/recording-mailer.service"; describe("e2e: sign-up triggers welcome email via cross-feature event", () => { beforeEach(() => { __resetBindStateForTests(); }); it("delivers a welcome email after a successful sign-up", async () => { await bindAllDevSeed(); const mailer = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.IMailerService, ) as RecordingMailerService; const controller = authContainer.get(AUTH_SYMBOLS.ISignUpController); await controller({ email: "test@example.com", password: "secret123" }); // The job runs on setImmediate; let microtasks settle await new Promise((r) => setImmediate(r)); expect(mailer.sent).toEqual([{ userId: expect.any(String), email: "test@example.com" }]); }); }); ``` > The exact import paths depend on the actual public-API exposure of the auth and marketing-pages packages. If `authContainer` is not exported, replace with a `getAuthContainer(config)` form. - [ ] **Step 3: Run; expect PASS** Run: `pnpm --filter @repo/web-next test -- sign-up-welcome-email` Expected: PASS. > If timing of `setImmediate` is flaky, replace with a synchronous in-memory bus that awaits handlers (default `InMemoryEventBus`) and ensure `bindAllDevSeed` uses it (it does — InMemoryEventBus is the dev-seed default). - [ ] **Step 4: Commit** ```bash git add apps/web-next/tests/sign-up-welcome-email.test.ts git commit -m "test(web-next): e2e cross-feature sign-up→welcome-email flow" ``` --- ## Phase 9 — Documentation ### Task 50: Write `docs/decisions/adr-015-events-and-jobs.md` **Files:** - Create: `docs/decisions/adr-015-events-and-jobs.md` - [ ] **Step 1: Read the format of recent ADRs** Read `docs/decisions/adr-014-instrumentation-sentry.md` for the structure (Status / Context / Decision / Consequences). - [ ] **Step 2: Write ADR-015** Distill the spec's § 2.2 (rules E0/E1/J0), § 3 (new packages), § 6.3 (bindAll rule), § 7 (boundary/lint), and § 11 (out of scope) into the ADR format. Each rule gets one paragraph. Cross-link the spec at `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`. - [ ] **Step 3: Commit** ```bash git add docs/decisions/adr-015-events-and-jobs.md git commit -m "docs(adr-015): cross-feature events and background jobs" ``` ### Task 51: Write `docs/guides/events-and-jobs.md` **Files:** - Create: `docs/guides/events-and-jobs.md` - [ ] **Step 1: Structure the guide** Three sections: 1. "Publish an event" — runs `gen event publish`, fills the schema, modifies a use case, updates DI binding. Show the full diff for each step. 2. "Consume an event" — runs `gen event consume`, edits the handler body, adds the import to bind files. Full diff. 3. "Add a job" — runs `gen job`, fills the schema and body, edits Payload `inputSchema`, updates the bind block. Full diff. For each section, reference the spec sections that explain the rationale, and list the verification commands. - [ ] **Step 2: Commit** ```bash git add docs/guides/events-and-jobs.md git commit -m "docs(guide): events-and-jobs walkthrough" ``` ### Task 52: Update `AGENTS.md` **Files:** - Modify: `AGENTS.md` - [ ] **Step 1: Add a new section under Per-Package Conventions** Insert after the "Apps call `bindAll()` per feature at boot" section: ```markdown ### Cross-feature events and background jobs (Plan 10, ADR-015) Three rules: - **E0:** Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus. - **E1:** Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-* files (never re-exported, ESLint-enforced). - **J0:** Jobs are for *deferred* work, not abstraction. Synchronous code stays synchronous. `@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both are swapped by `bindAll()` using the same `USE_DEV_SEED` / `NODE_ENV` rules as repositories. Per-feature folders (all optional): `events/.event.ts`, `events/handlers/on--.handler.ts`, `jobs/.job.ts`, `integrations/cms/jobs/.task.ts`. Use the generators: `pnpm turbo gen event {publish|consume}`, `pnpm turbo gen job`. They insert at six fixed `// ` anchor comments present in every feature. See `docs/guides/events-and-jobs.md` and `docs/decisions/adr-015-events-and-jobs.md`. ``` - [ ] **Step 2: Add a row to the Specification & Guides list** ```markdown - **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work ``` - [ ] **Step 3: Commit** ```bash git add AGENTS.md git commit -m "docs(agents): events-and-jobs section + guide reference" ``` ### Task 53: Update `CLAUDE.md` **Files:** - Modify: `CLAUDE.md` - [ ] **Step 1: Update Quick Start** Add to the command block: ```bash pnpm turbo gen event # Scaffold an event contract or handler pnpm turbo gen job # Scaffold a background job ``` - [ ] **Step 2: Update Read First** Add: `- docs/guides/events-and-jobs.md — publish/consume/schedule cookbook` - [ ] **Step 3: Update Key Conventions** Add three short bullets matching E0, E1, J0 from the AGENTS.md section. - [ ] **Step 4: Commit** ```bash git add CLAUDE.md git commit -m "docs(claude): generators + events/jobs conventions" ``` ### Task 54: Update `docs/guides/scaffolding-a-feature.md` **Files:** - Modify: `docs/guides/scaffolding-a-feature.md` - [ ] **Step 1: Add two new sections at the bottom** ```markdown ## Adding events and jobs to a feature Once a feature exists, augment it with cross-feature events or background jobs using the dedicated generators. See `docs/guides/events-and-jobs.md` for full walkthroughs. ```bash pnpm turbo gen event publish # publisher contract pnpm turbo gen event consume # consumer handler pnpm turbo gen job # background job ``` The generators insert at six fixed `// ` anchor comments. Generated features include them automatically; pre-existing features were retrofitted in ADR-015. ``` - [ ] **Step 2: Commit** ```bash git add docs/guides/scaffolding-a-feature.md git commit -m "docs(scaffolding): event/job generators reference" ``` ### Task 55: Update `docs/architecture/vertical-feature-spec.md` deferred lines **Files:** - Modify: `docs/architecture/vertical-feature-spec.md` - [ ] **Step 1: Update the deferred placeholders** Line 260 (currently "No `effects/`, `jobs/`, `events/` unless the feature grows them") becomes: ``` Optional `events/`, `jobs/`, `integrations/cms/jobs/` directories (see ADR-015 and `docs/guides/events-and-jobs.md`); features may grow them on demand. The spec's canonical layout below remains correct as the minimum. ``` Line 662 ("no `core-events` package yet; spec addendum v4's optional `core-events` stays deferred") becomes: ``` Cross-feature events: shipped via `@repo/core-events` (ADR-015, 2026-05-08). Jobs: shipped via `@repo/core-shared/jobs` + Payload's job queue (same ADR). ``` - [ ] **Step 2: Commit** ```bash git add docs/architecture/vertical-feature-spec.md git commit -m "docs(spec): events/jobs no longer deferred (ADR-015)" ``` --- ## Final verification ### Task 56: Whole-monorepo green check - [ ] **Step 1: Full lint** Run: `pnpm lint` Expected: PASS. - [ ] **Step 2: Full typecheck** Run: `pnpm typecheck` Expected: PASS. - [ ] **Step 3: Full test** Run: `pnpm test` Expected: PASS — including the new anchor-presence guard, the e2e cross-feature flow, and all rule tests. - [ ] **Step 4: Boundary check** Run: `pnpm turbo boundaries` Expected: PASS. - [ ] **Step 5: Dry-run smoke of all three generators against a sandbox** ```bash cp -r ~/Documents/Projects/template-vertical /tmp/final-test && cd /tmp/final-test && pnpm install pnpm turbo gen feature --args widgets Widget widgets pnpm turbo gen event --args publish widgets thing.happened pnpm turbo gen event --args consume blog thing.happened widgets pnpm turbo gen job --args widgets process-thing void # manual edits as printed by each generator pnpm typecheck test lint ``` Expected: end-to-end green. ```bash rm -rf /tmp/final-test ``` - [ ] **Step 6: Final commit** If any step revealed a fix, commit it. Otherwise no-op — the plan is done. --- ## Known follow-up — out of v1 plan scope Tracked here so they aren't forgotten: 1. **Spec interface drift — 3-arg `subscribe`.** During plan self-review, `IEventBus.subscribe` was widened to take `consumerFeature: string` as a second arg (between `descriptor` and `handler`) so `PayloadJobsEventBus` is directly assignable to `IEventBus`. The spec at `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md` § 3.3 still shows the older 2-arg form. **As part of Task 50 (ADR-015), update the spec § 3.3 + § 5.4 to the 3-arg form** so the spec and code agree. Do this in the same commit as the ADR. 2. **Cron schedules are out-of-band.** Job cron schedules live in `core-cms`'s `buildConfig({ jobs: { ... } })`, not in the feature's job file or the generator output. v1 documents this in Task 51's guide; v2 may add a `--cron` prompt to `gen job`. 3. **Production-mode e2e test.** Task 49's e2e test runs in dev-seed mode (`InMemoryEventBus`). A parallel test that runs in production mode (`PayloadJobsEventBus` against a Payload test database) would prove the `__events.*.task.ts` slug-to-handler chain end-to-end. Out of scope for v1 because it requires a Payload test fixture; the production-bus unit tests (Task 15) plus the generator's typecheck-on-output cover the wiring statically. The first item is mandatory and folded into Task 50. The remaining two are explicitly deferred and should be tracked in ADR-015's "Out of scope" list. > **Production bus task auto-registration is now in scope** (Tasks 39–40, 47). The `gen event consume` generator emits the `__events--.task.ts` Payload task alongside the handler, and the consumer's `bind-*` files bind the wrapped handler into the per-feature container so the task can resolve it. With this, `PayloadJobsEventBus` is fully wired end-to-end — no manual per-event task hand-write required. --- ## Self-review check This plan was self-reviewed against the spec on the date of writing. Key points verified: - Every spec section maps to one or more tasks (§ 2 → Phase 0, § 3 → Phases 1–2, § 4 → § 5 maps via Tasks 26–32 + generators, § 6 → Phases 1–2 + Phase 6, § 7 → Phase 4, § 8 → Phase 3, § 9 → Phase 7, § 10 → Phase 9, § 11 not implemented intentionally, § 12 honored throughout, § 13 verified by Task 49 + Task 56). - All file paths are absolute or repo-relative; no "TBD". - Tests precede implementation in every behavior-adding task. - Commit cadence: one commit per task, ~56 commits total, each green at the boundary. - Anchor protocol is consistent across Phases 5, 6, and 7. - Span+capture sandwich tags (`op: "event-handler"`, `op: "job"`, `layer: "event-handler"`, `layer: "job"`) appear in the same form in Tasks 40 and 42 generator output and Task 48 manual edits. - One consistency issue caught during review and fixed inline: `IEventBus.subscribe` widened to 3-arg form so `PayloadJobsEventBus` is directly assignable. See Known Follow-up #1. - One scope expansion accepted post-review per user request: `gen event consume` now also emits the `__events--.task.ts` Payload task and binds the wrapped handler into the per-feature container, closing the production-bus loop end-to-end (Tasks 39–40, 47). The original "deferred task-registration gap" is now obsolete.