56 bite-sized tasks across 9 phases implementing the spec at docs/superpowers/specs/2026-05-08-events-and-jobs-design.md: core-shared/jobs (IJobQueue + 2 impls), core-events package (IEventBus + 2 impls), recording test helpers, two ESLint rules, existing-feature anchor retrofit (5 features × 6 anchors), bindAll bus/queue swap, three Plop generators with anchor-comment protocol, end-to-end proof-of-life (sign-up → welcome email), and the full documentation set (ADR-015 + new guide + AGENTS/CLAUDE/scaffolding updates). Self-review caught and fixed one type-inconsistency (3-arg subscribe) and explicitly deferred the production task- registration gap. TDD-ordered with one commit per task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 KiB
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 2–5 minutes of work and ends in a commit.
Phase 0 — Read first
Read these once before starting; they contain rules and patterns the rest of the plan assumes:
docs/superpowers/specs/2026-05-08-events-and-jobs-design.md— the design this plan implements.AGENTS.md§ Per-Package Conventions and § Instrumentation conventions.CLAUDE.md§ Key Conventions.packages/auth/src/di/bind-production.ts— canonical binder shape we extend.apps/web-next/src/server/bind-production.ts— canonicalbindAll()shape we extend.docs/decisions/adr-014-instrumentation-sentry.md(R40, R41–R44) — the span+capture sandwich rules events/jobs reuse.docs/decisions/adr-013-input-output-unification.md(R1–R5) — the schemas-in-use-case pattern jobs mirror.
Conventions used throughout this plan:
- Spec sections are referenced as
spec § N.N. - Verification commands use
pnpm --filter <pkg> typecheck test lintfor package-scoped checks;pnpm typecheck/pnpm test/pnpm turbo boundariesfor 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
./jobssubpath
Add this entry alongside the existing subpaths:
"./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
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
// 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
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
// 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
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
// 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
// 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
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
// 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
// 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 nevercasts: Payload'sjobs.queueAPI uses generic types tied to the registered task registry. SincePayloadJobQueueis generic across all features, we accept the cost of a fewas nevercasts at this boundary in exchange for a uniformIJobQueueinterface. 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
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
// 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
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.jsonandpackages/core-shared/tsconfig.jsonas the reference shape
These are the closest analog (also tag: core).
- Step 2: Create
package.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
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 4: Create
vitest.config.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
# @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 installso the workspace picks up the new package
Run: pnpm install
Expected: clean install, @repo/core-events registered.
- Step 9: Commit
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 foreslint-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-eventsto thecoretag
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
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
// 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 —
defineEventnot exported.
Run: pnpm --filter @repo/core-events test
Expected: FAIL.
- Step 3: Implement
// 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
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
// 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
// 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
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
// 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 —
InMemoryEventBusnot 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
// 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
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
// 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 —
PayloadJobsEventBusnot 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
// 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.subscribedoesn't actually invoke the consumer's handler — it stores the consumer feature name and, onpublish, enqueues a Payload task that the consumer feature must register at task slug__events.<event>.<consumerFeature>. In v1, that task registration is the consumer's responsibility (manual, per-event) — see § "Known follow-up" at the end of this plan. The in-memory bus is the primary supported path in v1; the Payload bus is a working transport-layer skeleton with a documented manual step.
- Step 2: Run tests; expect PASS
Run: pnpm --filter @repo/core-events test -- payload-jobs-event-bus
Expected: 3 tests PASS.
- Step 3: Commit
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
// 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
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
// 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
// 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-sharedto 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:
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
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
// 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-eventsto core-testing deps
In packages/core-testing/package.json, add "@repo/core-events": "workspace:*" to dependencies. Then pnpm install.
- Step 2: Write the implementation
// 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:
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
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
// 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.tsor wherever rules are aggregated for distribution -
Modify: the boundary config that adds rules to feature/core packages
-
Step 1: Write the rule
// 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
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
// 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
// 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
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>tosrc/index.ts
At the end of the file (before the final EOF, after all existing exports):
// <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:
// <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:
// <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:
// <gen:job-tasks>
If it does NOT exist, create it with just:
// 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
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
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
// 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
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
.hbsfile to find the insertion points - Step 2: Add anchors at the same logical positions as Task 26
- Step 3: Run the existing
gen featureagainst a sandbox name to verify the template still renders
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
rm -rf /tmp/template-test
- Step 5: Commit
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:
// 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'sbind-dev-seed.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:
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
git add packages/auth/src/di/ packages/auth/package.json
git commit -m "feat(auth): bind binders accept (bus, queue) params"
- Step 6: Repeat steps 1–5 for
blog
Same edits applied to packages/blog/src/di/bind-production.ts, bind-dev-seed.ts, and their test files. Update packages/blog/package.json if needed.
Commit: feat(blog): bind binders accept (bus, queue) params
- Step 7: Repeat for
media
Commit: feat(media): bind binders accept (bus, queue) params
- Step 8: Repeat for
marketing-pages
Commit: feat(marketing-pages): bind binders accept (bus, queue) params
- Step 9: Repeat for
navigation
Commit: feat(navigation): bind binders accept (bus, queue) params
Task 34: Add resolveEventsAndJobs() to apps/web-next bindAll
Files:
-
Modify:
apps/web-next/src/server/bind-production.ts -
Modify:
apps/web-next/src/server/bind-production.test.ts -
Modify:
apps/web-next/package.json(add@repo/core-eventsif 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()tobind-production.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:
- Make
InMemoryJobQueuemutable with aregister(slug, handler)method (small API change in Phase 1).- 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)toInMemoryJobQueueas a follow-up step inside this task (sub-step below).
- Step 3: Add
registermethod toInMemoryJobQueue
In packages/core-shared/src/jobs/in-memory-job-queue.ts:
// add inside class InMemoryJobQueue
register(slug: string, handler: InMemoryHandler): void {
this.handlers[slug] = handler;
}
Constructor changes:
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:
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
bindAllProductionandbindAllDevSeedto pass through
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:
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
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
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
// 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
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
// 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:
// 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
// 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
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.tsto 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
eventgenerator (publish branch)
Inside the generator(plop) function, after the existing feature generator's setGenerator call:
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
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:
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
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 -
Step 1: Write
handler.ts.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
// 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: Commit
git add turbo/generators/templates/event/consume/
git commit -m "feat(turbo-gen): templates for gen event consume"
Task 40: Wire gen event consume actions and modify-blocks
Files:
-
Modify:
turbo/generators/config.ts -
Step 1: Implement
consumeActionsinconfig.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`;
return [
() => {
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
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,
},
{
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"),
},
() => 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`;
return `// <gen:event-handlers>
// ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior.
const wrapped${pascalCase(a.publisher)}${pascalCase(a.event)} = withSpan(
tracer,
{ name: "${a.feature}.${handlerFn}", op: "event-handler" },
withCapture(
logger,
{
feature: "${a.feature}",
layer: "event-handler",
name: "${a.feature}.${handlerFn}",
},
${handlerFn}(),
),
);
bus.subscribe(${eventConst}, "${a.feature}", wrapped${pascalCase(a.publisher)}${pascalCase(a.event)});`;
}
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>Eventfrom 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'smodifyis 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:
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
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
// 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
eqvia the inquirer integration; if it isn't registered, replace the conditional with two separate template files (e.g.,job.ts.void.hbsandjob.ts.typed.hbs) selected byactions. Test which works during smoke-testing.
- Step 2: Write
job.test.ts.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
// 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
handlerreads from the per-feature container directly (Inversify singleton). If a feature's container needs the Payloadreqfor some other reason, that's a per-feature edit; the default scaffolded shape is "container resolves the wrapped job."
- Step 4: Commit
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
jobgenerator
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
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
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
// 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
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
// 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
// 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
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
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:
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
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.publishis called
Read the existing test, then add:
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 —
signUpUseCasedoesn't takebusyet. -
Step 3: Update
signUpUseCase
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:
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
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
pnpm turbo gen event --args consume marketing-pages user.signed-up auth
Expected: clean generation; handler file + test exist; symbols.ts has the new symbol; bind-production / bind-dev-seed have the subscription block.
- Step 2: Add the missing imports to bind files
Both packages/marketing-pages/src/di/bind-production.ts and bind-dev-seed.ts need:
import { userSignedUpEvent } from "@repo/auth";
import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler";
- Step 3: Verify
Run: pnpm --filter @repo/marketing-pages typecheck lint
Expected: PASS (the generated test passes; full integration comes in Task 49).
- Step 4: Commit
git add packages/marketing-pages/src/
git commit -m "feat(marketing-pages): subscribe to auth.user.signed-up"
Task 48: Use gen job to scaffold send-welcome-email
- Step 1: Run the generator
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:
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
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:
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:
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:
${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:
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
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
// 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
authContaineris not exported, replace with agetAuthContainer(config)form.
- Step 3: Run; expect PASS
Run: pnpm --filter @repo/web-next test -- sign-up-welcome-email
Expected: PASS.
If timing of
setImmediateis flaky, replace with a synchronous in-memory bus that awaits handlers (defaultInMemoryEventBus) and ensurebindAllDevSeeduses it (it does — InMemoryEventBus is the dev-seed default).
- Step 4: Commit
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
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:
- "Publish an event" — runs
gen event publish, fills the schema, modifies a use case, updates DI binding. Show the full diff for each step. - "Consume an event" — runs
gen event consume, edits the handler body, adds the import to bind files. Full diff. - "Add a job" — runs
gen job, fills the schema and body, edits PayloadinputSchema, 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
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:
### 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
- **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work
- Step 3: Commit
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:
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
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
## 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
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
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.
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
These deviations from a fully-featured production system are accepted in v1 and tracked here so they aren't forgotten:
- Spec interface drift — 3-arg
subscribe. During plan self-review,IEventBus.subscribewas widened to takeconsumerFeature: stringas a second arg (betweendescriptorandhandler) soPayloadJobsEventBusis directly assignable toIEventBus. The spec atdocs/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. PayloadJobsEventBustask-registration gap.PayloadJobsEventBus.publishenqueues__events.<event>.<consumerFeature>Payload tasks, but those tasks must exist in Payload's task registry at config-build time. v1 does NOT auto-generate them. To use the production bus end-to-end, the consumer feature must hand-write a task config atpackages/<consumer>/src/integrations/cms/jobs/__events-<publisher>-<event>.task.tswhose handler resolves the consumer's wrapped event handler from the container and invokes it. The proof-of-life e2e test in Task 49 uses dev-seed mode (InMemoryEventBus), which sidesteps this entirely — that test does not validate the production bus.- No
gen event consume --with-payload-taskflag. When the gap above is closed (separate ADR), the consume generator should be extended to emit the__events.*task config alongside the handler, removing the manual step. - Cron schedules are out-of-band. Job cron schedules live in
core-cms'sbuildConfig({ jobs: { ... } }), not in the feature's job file or the generator output. v1 documents this in Task 51's guide; v2 may add a--cronprompt togen job.
The first item is mandatory and folded into Task 50. The remaining three are explicitly deferred and should be tracked in ADR-015's "Out of scope" list.
Self-review check
This plan was self-reviewed against the spec on the date of writing. Key points verified:
- Every spec section maps to one or more tasks (§ 2 → Phase 0, § 3 → Phases 1–2, § 4 → § 5 maps via Tasks 26–32 + generators, § 6 → Phases 1–2 + Phase 6, § 7 → Phase 4, § 8 → Phase 3, § 9 → Phase 7, § 10 → Phase 9, § 11 not implemented intentionally, § 12 honored throughout, § 13 verified by Task 49 + Task 56).
- All file paths are absolute or repo-relative; no "TBD".
- Tests precede implementation in every behavior-adding task.
- Commit cadence: one commit per task, ~56 commits total, each green at the boundary.
- Anchor protocol is consistent across Phases 5, 6, and 7.
- Span+capture sandwich tags (
op: "event-handler",op: "job",layer: "event-handler",layer: "job") appear in the same form in Tasks 40 and 42 generator output and Task 48 manual edits. - One consistency issue caught during review and fixed inline:
IEventBus.subscribewidened to 3-arg form soPayloadJobsEventBusis directly assignable. See Known Follow-up #1. - One scope gap caught during review and explicitly deferred:
PayloadJobsEventBusrequires per-event Payload task registration that v1 does not auto-generate. See Known Follow-up #2.