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