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:
@@ -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**
|
- [ ] **Step 2: Run tests; expect PASS**
|
||||||
|
|
||||||
@@ -2271,6 +2271,7 @@ git commit -m "feat(turbo-gen): event generator (publish mode)"
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Create: `turbo/generators/templates/event/consume/handler.ts.hbs`
|
- 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/handler.test.ts.hbs`
|
||||||
|
- Create: `turbo/generators/templates/event/consume/event-task.ts.hbs`
|
||||||
|
|
||||||
- [ ] **Step 1: Write `handler.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
|
```bash
|
||||||
git add turbo/generators/templates/event/consume/
|
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
|
### 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 symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
||||||
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
||||||
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
||||||
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
|
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
|
||||||
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
|
||||||
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
||||||
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
||||||
return "All required anchors present";
|
return "All required anchors present";
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2355,6 +2386,15 @@ function consumeActions(a: {
|
|||||||
templateFile: "templates/event/consume/handler.test.ts.hbs",
|
templateFile: "templates/event/consume/handler.test.ts.hbs",
|
||||||
data: a,
|
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",
|
type: "modify",
|
||||||
path: symbolFile,
|
path: symbolFile,
|
||||||
@@ -2373,6 +2413,14 @@ function consumeActions(a: {
|
|||||||
pattern: /\/\/ <gen:event-handlers>/,
|
pattern: /\/\/ <gen:event-handlers>/,
|
||||||
template: handlerBindBlock(a, "dev-seed"),
|
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),
|
() => printConsumeNextSteps(a),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -2383,9 +2431,12 @@ function handlerBindBlock(
|
|||||||
): string {
|
): string {
|
||||||
const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
||||||
const eventConst = `${camelCase(a.event)}Event`;
|
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>
|
return `// <gen:event-handlers>
|
||||||
// ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior.
|
// ${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,
|
tracer,
|
||||||
{ name: "${a.feature}.${handlerFn}", op: "event-handler" },
|
{ name: "${a.feature}.${handlerFn}", op: "event-handler" },
|
||||||
withCapture(
|
withCapture(
|
||||||
@@ -2398,7 +2449,15 @@ function handlerBindBlock(
|
|||||||
${handlerFn}(),
|
${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 {
|
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
|
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**
|
- [ ] **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";
|
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`
|
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**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add packages/marketing-pages/src/
|
git add packages/marketing-pages/src/ packages/core-cms/src/
|
||||||
git commit -m "feat(marketing-pages): subscribe to auth.user.signed-up"
|
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`
|
### 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
|
## 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.
|
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.
|
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. **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.
|
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.
|
||||||
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`.
|
|
||||||
|
|
||||||
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-<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.
|
- 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.
|
- 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 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 39–40, 47). The original "deferred task-registration gap" is now obsolete.
|
||||||
|
|||||||
Reference in New Issue
Block a user