docs(plan): bring production-bus task auto-registration into scope

gen event consume now emits a second file: an __events-<publisher>-
<event>.task.ts Payload task that delegates to the consumer's
container-resolved wrapped handler. The bind block also binds the
wrapped handler into the per-feature container by symbol so the
task can resolve it. With this, PayloadJobsEventBus is fully wired
end-to-end with no manual per-event task hand-write required.
Removes the corresponding Known Follow-up item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:28:25 +02:00
parent 42fb3996d3
commit c2a32363a8

View File

@@ -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.<event>.<consumerFeature>`. **In v1, that task registration is the consumer's responsibility (manual, per-event)** — see § "Known follow-up" at the end of this plan. The in-memory bus is the primary supported path in v1; the Payload bus is a working transport-layer skeleton with a documented manual step.
> **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**
@@ -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<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"
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, ["// <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";
},
{
@@ -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.<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,
@@ -2373,6 +2413,14 @@ function consumeActions(a: {
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),
];
}
@@ -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 `// <gen:event-handlers>
// ${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.<event>.<consumerFeature>` Payload tasks, but those tasks must exist in Payload's task registry at config-build time. v1 does NOT auto-generate them. To use the production bus end-to-end, the consumer feature must hand-write a task config at `packages/<consumer>/src/integrations/cms/jobs/__events-<publisher>-<event>.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 3940, 47). The `gen event consume` generator emits the `__events-<publisher>-<event>.task.ts` Payload task alongside the handler, and the consumer's `bind-*` files bind the wrapped handler into the per-feature container so the task can resolve it. With this, `PayloadJobsEventBus` is fully wired end-to-end — no manual per-event task hand-write required.
---
@@ -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-<publisher>-<event>.task.ts` Payload task and binds the wrapped handler into the per-feature container, closing the production-bus loop end-to-end (Tasks 3940, 47). The original "deferred task-registration gap" is now obsolete.