diff --git a/docs/superpowers/plans/2026-05-08-events-and-jobs.md b/docs/superpowers/plans/2026-05-08-events-and-jobs.md index 33943d7..ed2ee02 100644 --- a/docs/superpowers/plans/2026-05-08-events-and-jobs.md +++ b/docs/superpowers/plans/2026-05-08-events-and-jobs.md @@ -987,7 +987,7 @@ export class PayloadJobsEventBus implements IEventBus { } ``` -> **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 that the *consumer feature* must register at task slug `__events..`. **In v1, that task registration is the consumer's responsibility (manual, per-event)** — see § "Known follow-up" at the end of this plan. The in-memory bus is the primary supported path in v1; the Payload bus is a working transport-layer skeleton with a documented manual step. +> **Implementation note:** `PayloadJobsEventBus.subscribe` doesn't actually invoke the consumer's handler — it stores the consumer feature name and, on `publish`, enqueues a Payload task at slug `__events..`. The matching Payload task config is generated by `gen event consume` (Task 40 — see the additional `add` action that emits an `__events--.task.ts` alongside the handler). The task config delegates to the consumer's container-resolved wrapped handler. With the generator + `core-cms` aggregation, the production bus is fully wired end-to-end. - [ ] **Step 2: Run tests; expect PASS** @@ -2271,6 +2271,7 @@ git commit -m "feat(turbo-gen): event generator (publish mode)" **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`** @@ -2311,11 +2312,39 @@ describe("on{{pascalCase publisher}}{{pascalCase event}}Handler", () => { }); ``` -- [ ] **Step 3: Commit** +- [ ] **Step 3: Write `event-task.ts.hbs`** (Payload task that completes the production bus loop) + +```hbs +// packages/{{kebabCase feature}}/src/integrations/cms/jobs/__events-{{kebabCase publisher}}-{{kebabCase event}}.task.ts +// Generated by `gen event consume`. The PayloadJobsEventBus enqueues this task +// when {{kebabCase publisher}} publishes `{{kebabCase publisher}}.{{event}}`. The handler +// resolves the consumer's wrapped event handler from the per-feature container +// and invokes it with the typed payload. +import type { TaskConfig } from "payload"; +import { {{camelCase feature}}Container } from "../../../di/container"; +import { {{constantCase feature}}_SYMBOLS } from "../../../di/symbols"; +import type { IOn{{pascalCase publisher}}{{pascalCase event}}Handler } from "../../../events/handlers/on-{{kebabCase publisher}}-{{kebabCase event}}.handler"; +import type { {{pascalCase event}}Event } from "@repo/{{kebabCase publisher}}"; + +export const on{{pascalCase publisher}}{{pascalCase event}}EventTask: TaskConfig<"__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}"> = { + slug: "__events.{{kebabCase publisher}}.{{event}}.{{kebabCase feature}}", + inputSchema: [], + retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } }, + handler: async ({ input }) => { + const handler = {{camelCase feature}}Container.get( + {{constantCase feature}}_SYMBOLS.IOn{{pascalCase publisher}}{{pascalCase event}}Handler, + ); + await handler(input as {{pascalCase event}}Event); + return { output: {} }; + }, +}; +``` + +- [ ] **Step 4: Commit** ```bash git add turbo/generators/templates/event/consume/ -git commit -m "feat(turbo-gen): templates for gen event consume" +git commit -m "feat(turbo-gen): templates for gen event consume (handler + Payload task)" ``` ### Task 40: Wire `gen event consume` actions and modify-blocks @@ -2336,11 +2365,13 @@ function consumeActions(a: { const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; + const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`; return [ () => { assertAnchors(process.cwd(), symbolFile, ["// "]); assertAnchors(process.cwd(), bindProdFile, ["// "]); assertAnchors(process.cwd(), bindDevFile, ["// "]); + assertAnchors(process.cwd(), cmsIndexFile, ["// "]); return "All required anchors present"; }, { @@ -2355,6 +2386,15 @@ function consumeActions(a: { templateFile: "templates/event/consume/handler.test.ts.hbs", data: a, }, + // Production-bus task config — completes the PayloadJobsEventBus loop. + // Slug must match what PayloadJobsEventBus.publish enqueues: + // __events... + { + type: "add", + path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`, + templateFile: "templates/event/consume/event-task.ts.hbs", + data: a, + }, { type: "modify", path: symbolFile, @@ -2373,6 +2413,14 @@ function consumeActions(a: { pattern: /\/\/ /, template: handlerBindBlock(a, "dev-seed"), }, + // Re-export the event-task config from integrations/cms/index.ts so + // core-cms picks it up when aggregating Payload tasks. + { + type: "modify", + path: cmsIndexFile, + pattern: /\/\/ /, + template: `// \nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`, + }, () => printConsumeNextSteps(a), ]; } @@ -2383,9 +2431,12 @@ function handlerBindBlock( ): string { const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; const eventConst = `${camelCase(a.event)}Event`; + const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; + const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`; + const containerVar = `${camelCase(a.feature)}Container`; return `// // ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior. - const wrapped${pascalCase(a.publisher)}${pascalCase(a.event)} = withSpan( + const ${wrappedVar} = withSpan( tracer, { name: "${a.feature}.${handlerFn}", op: "event-handler" }, withCapture( @@ -2398,7 +2449,15 @@ function handlerBindBlock( ${handlerFn}(), ), ); - bus.subscribe(${eventConst}, "${a.feature}", wrapped${pascalCase(a.publisher)}${pascalCase(a.event)});`; + // 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 { @@ -2916,7 +2975,14 @@ git commit -m "feat(auth): signUp publishes userSignedUpEvent" pnpm turbo gen event --args consume marketing-pages user.signed-up auth ``` -Expected: clean generation; handler file + test exist; symbols.ts has the new symbol; bind-production / bind-dev-seed have the subscription block. +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** @@ -2927,16 +2993,18 @@ import { userSignedUpEvent } from "@repo/auth"; import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler"; ``` -- [ ] **Step 3: Verify** +- [ ] **Step 3: Verify the generated task exists and `core-cms` aggregates it** Run: `pnpm --filter @repo/marketing-pages typecheck lint` -Expected: PASS (the generated test passes; full integration comes in Task 49). +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/ -git commit -m "feat(marketing-pages): subscribe to auth.user.signed-up" +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` @@ -3344,14 +3412,15 @@ If any step revealed a fix, commit it. Otherwise no-op — the plan is done. ## Known follow-up — out of v1 plan scope -These deviations from a fully-featured production system are accepted in v1 and tracked here so they aren't forgotten: +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. **`PayloadJobsEventBus` task-registration gap.** `PayloadJobsEventBus.publish` enqueues `__events..` Payload tasks, but those tasks must exist in Payload's task registry at config-build time. v1 does NOT auto-generate them. To use the production bus end-to-end, the consumer feature must hand-write a task config at `packages//src/integrations/cms/jobs/__events--.task.ts` whose handler resolves the consumer's wrapped event handler from the container and invokes it. The proof-of-life e2e test in Task 49 uses dev-seed mode (`InMemoryEventBus`), which sidesteps this entirely — that test does not validate the production bus. -3. **No `gen event consume --with-payload-task` flag.** When the gap above is closed (separate ADR), the consume generator should be extended to emit the `__events.*` task config alongside the handler, removing the manual step. -4. **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`. +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 three are explicitly deferred and should be tracked in ADR-015's "Out of scope" list. +The first item is mandatory and folded into Task 50. The remaining two are explicitly deferred and should be tracked in ADR-015's "Out of scope" list. + +> **Production bus task auto-registration is now in scope** (Tasks 39–40, 47). The `gen event consume` generator emits the `__events--.task.ts` Payload task alongside the handler, and the consumer's `bind-*` files bind the wrapped handler into the per-feature container so the task can resolve it. With this, `PayloadJobsEventBus` is fully wired end-to-end — no manual per-event task hand-write required. --- @@ -3366,4 +3435,4 @@ This plan was self-reviewed against the spec on the date of writing. Key points - 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 gap caught during review and explicitly deferred: `PayloadJobsEventBus` requires per-event Payload task registration that v1 does not auto-generate. See Known Follow-up #2. +- One scope expansion accepted post-review per user request: `gen event consume` now also emits the `__events--.task.ts` Payload task and binds the wrapped handler into the per-feature container, closing the production-bus loop end-to-end (Tasks 39–40, 47). The original "deferred task-registration gap" is now obsolete.