Files
agentic-dev/docs/superpowers/plans/2026-05-08-events-and-jobs.md
Danijel Martinek c2a32363a8 docs(plan): bring production-bus task auto-registration into scope
gen event consume now emits a second file: an __events-<publisher>-
<event>.task.ts Payload task that delegates to the consumer's
container-resolved wrapped handler. The bind block also binds the
wrapped handler into the per-feature container by symbol so the
task can resolve it. With this, PayloadJobsEventBus is fully wired
end-to-end with no manual per-event task hand-write required.
Removes the corresponding Known Follow-up item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:28:25 +02:00

3439 lines
117 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `// <gen:*>` 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 25 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, R41R44) — the span+capture sandwich rules events/jobs reuse.
- `docs/decisions/adr-013-input-output-unification.md` (R1R5) — 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 <pkg> 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/<feature>/src/index.ts # + // <gen:events>
packages/<feature>/src/di/symbols.ts # + // <gen:event-handler-symbols> and // <gen:job-symbols>
packages/<feature>/src/di/bind-production.ts # + // <gen:event-handlers> and // <gen:jobs>
packages/<feature>/src/di/bind-dev-seed.ts # + same two anchors
packages/<feature>/src/integrations/cms/index.ts # + // <gen:job-tasks>
```
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/<feature>/src/di/bind-production.ts # + bus, queue params
packages/<feature>/src/di/bind-dev-seed.ts # + bus, queue params
packages/<feature>/src/di/bind-production.test.ts # update test invocations
packages/<feature>/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<T>(
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> | void;
export class InMemoryJobQueue implements IJobQueue {
private counter = 0;
constructor(private readonly handlers: Record<string, InMemoryHandler>) {}
async enqueue<T>(
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<T>(
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<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
): EventDescriptor<TName, TSchema> {
return { name, schema };
}
```
- [ ] **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<T> = (event: T) => Promise<void>;
export interface IEventBus {
publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
/**
* Subscribe a handler. `consumerFeature` is the kebab-case name of the
* subscribing feature (e.g., "marketing-pages"). InMemoryEventBus uses it
* only as a debug tag; PayloadJobsEventBus uses it to name the fan-out task
* slug deterministically (`__events.<event>.<consumerFeature>`).
*/
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
handler: EventHandler<T>,
): void;
}
```
- [ ] **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<string, EventHandler<unknown>[]>();
constructor(private readonly options: InMemoryEventBusOptions = {}) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const subscribers = this.handlers.get(descriptor.name) ?? [];
if (subscribers.length === 0) return;
const settled = await Promise.allSettled(
subscribers.map((h) => h(payload)),
);
if (this.options.failFast) {
const failure = settled.find((s) => s.status === "rejected");
if (failure && failure.status === "rejected") throw failure.reason;
}
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
_consumerFeature: string, // tagging only; no behavior
handler: EventHandler<T>,
): void {
const arr = this.handlers.get(descriptor.name) ?? [];
arr.push(handler as EventHandler<unknown>);
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.<event>.<consumer>`", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
bus.subscribe(evt, "marketing-pages", vi.fn());
bus.subscribe(evt, "blog", vi.fn());
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(2);
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
"__events.auth.user.signed-up.blog",
"__events.auth.user.signed-up.marketing-pages",
]);
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
});
it("enqueues nothing when no subscribers are registered", async () => {
const queue = recordingQueue();
const bus = new PayloadJobsEventBus(queue);
await bus.publish(evt, { userId: "u1" });
expect(queue.enqueued).toHaveLength(0);
});
});
```
- [ ] **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<string, string[]>(); // event name → consumer-feature names
constructor(private readonly queue: IJobQueue) {}
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
const consumers = this.subscribers.get(descriptor.name) ?? [];
await Promise.all(
consumers.map((consumerFeature) =>
this.queue.enqueue(
`__events.${descriptor.name}.${consumerFeature}`,
payload,
),
),
);
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
_handler: EventHandler<T>,
): void {
const arr = this.subscribers.get(descriptor.name) ?? [];
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
this.subscribers.set(descriptor.name, arr);
}
}
```
> **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.<event>.<consumerFeature>`. The matching Payload task config is generated by `gen event consume` (Task 40 — see the additional `add` action that emits an `__events-<publisher>-<event>.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<T>(
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<string, EventHandler<unknown>[]>();
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
this.published.push({ name: descriptor.name, payload });
for (const h of this.handlers.get(descriptor.name) ?? []) await h(payload);
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
_consumerFeature: string,
handler: EventHandler<T>,
): void {
const arr = this.handlers.get(descriptor.name) ?? [];
arr.push(handler as EventHandler<unknown>);
this.handlers.set(descriptor.name, arr);
}
}
```
- [ ] **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 `// <gen:events>` to `src/index.ts`**
At the end of the file (before the final EOF, after all existing exports):
```ts
// <gen:events>
```
- [ ] **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
// <gen:event-handler-symbols>
// <gen:job-symbols>
```
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
// <gen:event-handlers>
// <gen:jobs>
```
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
// <gen:job-tasks>
```
If it does NOT exist, create it with just:
```ts
// packages/auth/src/integrations/cms/index.ts
// <gen:job-tasks>
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 // <gen:*> 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 // <gen:*> 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 // <gen:*> 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 // <gen:*> 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 // <gen:*> 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": ["// <gen:events>"],
"src/di/symbols.ts": ["// <gen:event-handler-symbols>", "// <gen:job-symbols>"],
"src/di/bind-production.ts": ["// <gen:event-handlers>", "// <gen:jobs>"],
"src/di/bind-dev-seed.ts": ["// <gen:event-handlers>", "// <gen:jobs>"],
"src/integrations/cms/index.ts": ["// <gen:job-tasks>"],
};
describe("// <gen:*> 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 // <gen:*> 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 `<gen:job-tasks>` 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 // <gen:*> anchor comments to feature template"
```
---
## Phase 6 — Per-feature binder signatures + bindAll() bus/queue swap
### Task 33: Extend `bindProduction<Feature>` and `bindDevSeed<Feature>` signatures
**Files (per feature, applied to all 5):**
- Modify: `packages/<feature>/src/di/bind-production.ts`
- Modify: `packages/<feature>/src/di/bind-production.test.ts`
- Modify: `packages/<feature>/src/di/bind-dev-seed.ts`
- Modify: `packages/<feature>/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<void> {
// ... 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 15 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<string, InMemoryHandler> = {}) {
this.handlers = { ...handlers };
}
private readonly handlers: Record<string, InMemoryHandler>;
```
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<void> {
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<void> {
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<typeof {{camelCase event}}EventSchema>;
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<typeof {{camelCase event}}EventSchema>;
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, ["// <gen:events>"]);
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: /\/\/ <gen:events>/,
template: `// <gen:events>\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<void> => {
// 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<IOn{{pascalCase publisher}}{{pascalCase event}}Handler>(
{{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, ["// <gen:event-handler-symbols>"]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
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.<publisher>.<event>.<consumer>
{
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: /\/\/ <gen:event-handler-symbols>/,
template: `// <gen:event-handler-symbols>\n I${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`,
},
{
type: "modify",
path: bindProdFile,
pattern: /\/\/ <gen:event-handlers>/,
template: handlerBindBlock(a, "production"),
},
{
type: "modify",
path: bindDevFile,
pattern: /\/\/ <gen:event-handlers>/,
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: /\/\/ <gen:job-tasks>/,
template: `// <gen:job-tasks>\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 `// <gen:event-handlers>
// ${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>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<typeof {{camelCase job}}InputSchema>;
export type I{{pascalCase job}}Job = ReturnType<typeof {{camelCase job}}Job>;
export const {{camelCase job}}Job =
() =>
async (input: {{pascalCase job}}Input): Promise<void> => {
{{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<I{{pascalCase job}}Job>(
{{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, ["// <gen:job-symbols>"]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:jobs>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:jobs>"]);
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
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: /\/\/ <gen:job-tasks>/,
template: `// <gen:job-tasks>\nexport { ${camelCase(a.job)}Task } from "./jobs/${a.job}.task";`,
},
{
type: "modify",
path: symbolFile,
pattern: /\/\/ <gen:job-symbols>/,
template: `// <gen:job-symbols>\n I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camelCase(a.job)}Job"),`,
},
{
type: "modify",
path: bindProdFile,
pattern: /\/\/ <gen:jobs>/,
template: jobBindBlock(a),
},
{
type: "modify",
path: bindDevFile,
pattern: /\/\/ <gen:jobs>/,
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 `// <gen:jobs>
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<void>;
}
```
- [ ] **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<void> {
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<SignUpOutput> => {
// ... 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<typeof sendWelcomeEmailInputSchema>;
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;
export const sendWelcomeEmailJob =
(mailer: IMailerService) =>
async (input: SendWelcomeEmailInput): Promise<void> => {
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<IMailerService>(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<typeof onAuthUserSignedUpHandler>;
export const onAuthUserSignedUpHandler =
(queue: IJobQueue) =>
async (event: UserSignedUpEvent): Promise<void> => {
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<ISendWelcomeEmailJob>(
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<IMailerService>(
MARKETING_PAGES_SYMBOLS.IMailerService,
) as RecordingMailerService;
const controller = authContainer.get<ISignUpController>(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/<x>.event.ts`, `events/handlers/on-<publisher>-<event>.handler.ts`, `jobs/<x>.job.ts`, `integrations/cms/jobs/<x>.task.ts`.
Use the generators: `pnpm turbo gen event {publish|consume}`, `pnpm turbo gen job`. They insert at six fixed `// <gen:*>` 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 `// <gen:*>` 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 3940, 47). The `gen event consume` generator emits the `__events-<publisher>-<event>.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 12, § 4 → § 5 maps via Tasks 2632 + generators, § 6 → Phases 12 + 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-<publisher>-<event>.task.ts` Payload task and binds the wrapped handler into the per-feature container, closing the production-bus loop end-to-end (Tasks 3940, 47). The original "deferred task-registration gap" is now obsolete.