The docs/superpowers/{specs,plans}/ directory was archived to .archive/
in an earlier session (and .archive/ is gitignored). Every md link
into that path is now a broken reference for anyone consuming the
template fresh.
Stripped:
- ADR-011: **Spec:** header line
- ADR-015: **Spec:** + **Plan:** header lines
- ADR-016: **Spec:** + **Plan:** header lines + footer "Spec —"
bullet (the design rationale is captured in the ADR body itself)
- ADR-017: **Spec:** + **Plan:** header lines
- ADR-018: **Spec:** + **Plan:** header lines
- guides/realtime.md: inline "the full spec" link + footer
[Spec] entry (folded its description into the ADR-016 entry)
- guides/events-and-jobs.md: inline "the full spec" link
- architecture/vertical-feature-spec.md: stale "Deleted" subsection
referencing docs/superpowers/plans/*
Updated:
- glossary.md "PRD" entry: clarified status flow now matches the
shipped pnpm work prd-ship lifecycle (draft -> in-review ->
approved -> shipped); removed the parenthetical pointing at
docs/superpowers/specs/ as a definition of "spec"
- glossary.md "spec" flagged-ambiguity: rewritten to reflect that
durable design lives in ADRs (docs/decisions/adr-NNN-*.md) and
implementation seeds live in PRDs (docs/work/prds/*.prd.md) —
"spec" should be avoided in this template
Preserved (legitimate refs to the SuperPowers plugin, not the dir):
- agent-first-workflow-and-conformance.md mentions of
`superpowers:brainstorming` — these reference the external
plugin skill, not a file in the repo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
Events and Jobs
Walkthrough for adding cross-feature events and background jobs to a feature. For the architectural rationale, see ADR-015.
Prerequisite —
@repo/core-eventsis optional. The event bus (IEventBus,InMemoryEventBus,PayloadJobsEventBus) lives in@repo/core-events, which ships as a scaffoldable package rather than a permanent fixture. Ifpackages/core-events/does not exist in your repo, runpnpm turbo gen core-package eventsfirst, then wire the bus intoapps/web-next/src/server/bind-production.tsas described in the generator's next-steps output.Background jobs (
IJobQueue,gen job) work without core-events — they only require@repo/core-shared/jobs. Cross-feature event fanout (gen event consume) additionally requires core-events.
The three rules to keep in mind:
- E0 — Events are for cross-feature decoupling. In-feature reactions are direct use-case calls.
- E1 — Event contracts are public; handlers are private (never re-exported, ESLint-enforced).
- J0 — Jobs are for deferred work (latency, retries, cron). Synchronous code stays synchronous.
Three generators do the boilerplate. Each one inserts at fixed // <gen:*> anchor comments that are present in every feature.
pnpm turbo gen event publish # publisher contract
pnpm turbo gen event consume # consumer handler + Payload event-task
pnpm turbo gen job # background job + Payload TaskConfig
1. Publish an event
Run from the repo root:
pnpm turbo gen event --args publish auth user.signed-up
This scaffolds:
packages/auth/src/events/user-signed-up.event.ts(the contract — descriptor + Zod schema + type alias)packages/auth/src/events/user-signed-up.event.test.ts- A re-export at the
// <gen:events>anchor inpackages/auth/src/index.ts
Then fill in the schema with the actual fields the event carries:
// packages/auth/src/events/user-signed-up.event.ts
export const userSignedUpEventSchema = z
.object({
userId: z.string(),
email: z.string().email(),
signedUpAt: z.string().datetime(),
})
.strict();
Then publish from a use case. Add bus: IEventBus to the factory signature and call bus.publish(...) after the success path:
// packages/auth/src/application/use-cases/sign-up.use-case.ts
import type { IEventBus } from "@repo/core-events";
import { userSignedUpEvent } from "../../events/user-signed-up.event";
export const signUpUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
bus: IEventBus,
) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
// ... existing logic ...
await bus.publish(userSignedUpEvent, {
userId: newUser.id,
email: `${newUser.username}@example.local`,
signedUpAt: new Date().toISOString(),
});
return signUpOutputSchema.parse({ session, cookie });
};
Then update DI. Both bind-production.ts and bind-dev-seed.ts already receive bus as a parameter; just thread it into the factory call:
const wrappedSignUp = withSpan(
tracer,
{ name: "auth.signUp", op: "use-case" },
withCapture(
logger,
{ feature: "auth", layer: "use-case", name: "auth.signUp" },
signUpUseCase(repo, authService, bus),
),
);
If the feature has a default-fallback module.ts that resolves use cases via .toDynamicValue(), give it a fresh new InMemoryEventBus() per resolution — the module is the test-mock fallback path, not a runtime path.
Verify:
pnpm --filter @repo/auth lint typecheck test
The publishing test asserts on the RecordingEventBus's published array. See signUpUseCase's test for the pattern.
2. Consume an event
Run from the consumer's perspective (here marketing-pages consumes auth):
pnpm turbo gen event --args consume marketing-pages user.signed-up auth
This scaffolds:
packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts+ testpackages/marketing-pages/src/integrations/cms/jobs/__events-auth-user-signed-up.task.ts(the Payload event-task that closes the production-bus loop)
…and modifies four files at their anchors:
src/di/symbols.ts— adds the handler symbol at// <gen:event-handler-symbols>src/di/bind-production.ts— wraps the handler in span+capture, binds to the symbol, and callsbus.subscribe(...)at// <gen:event-handlers>src/di/bind-dev-seed.ts— same as production, identical blocksrc/integrations/cms/index.ts— re-exports the event-task at// <gen:job-tasks>socore-cmsaggregates it
The generator prints two manual edits:
1. Add the imports at the top of both bind files (the modify-block can't add imports):
import { userSignedUpEvent } from "@repo/auth";
import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler";
2. Add the cross-feature dep to the consumer's package.json:
"@repo/auth": "workspace:*"
Then implement the handler body. The factory shape is (deps) => async (event) => Promise<void>. Inject what the handler needs — typically a job queue if the reaction is deferred:
// packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts
import type { UserSignedUpEvent } from "@repo/auth";
import type { IJobQueue } from "@repo/core-shared/jobs";
export const onAuthUserSignedUpHandler =
(queue: IJobQueue) =>
async (event: UserSignedUpEvent): Promise<void> => {
await queue.enqueue("marketing-pages.send-welcome-email", {
userId: event.userId,
email: event.email,
});
};
The generator emitted onAuthUserSignedUpHandler() with no args in the bind block. Edit it to pass queue:
onAuthUserSignedUpHandler(queue),
Verify:
pnpm --filter @repo/marketing-pages lint typecheck test
pnpm --filter @repo/core-cms typecheck
Handlers must NOT be re-exported from the consumer's public surface (rule E1 — enforced by core-eslint's no-handler-reexport rule).
3. Add a job
pnpm turbo gen job --args marketing-pages send-welcome-email typed
The third arg picks the input shape: void for parameter-less jobs, typed for jobs that take a payload.
This scaffolds:
packages/marketing-pages/src/jobs/send-welcome-email.job.ts+ testpackages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts
…and modifies four files at the job anchors (<gen:job-symbols>, both <gen:jobs>, <gen:job-tasks>).
Fill in the schema and body. Inject the dependencies you need:
// packages/marketing-pages/src/jobs/send-welcome-email.job.ts
import { z } from "zod";
import type { IMailerService } from "../application/services/mailer.service.interface";
export const sendWelcomeEmailInputSchema = z
.object({
userId: z.string(),
email: z.string().email(),
})
.strict();
export type SendWelcomeEmailInput = z.infer<typeof sendWelcomeEmailInputSchema>;
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;
export const sendWelcomeEmailJob =
(mailer: IMailerService) =>
async (input: SendWelcomeEmailInput): Promise<void> => {
sendWelcomeEmailInputSchema.parse(input);
await mailer.sendWelcome(input.userId, input.email);
};
Wire the dependency in both binders. The generator emitted sendWelcomeEmailJob(); edit to pass the mailer:
sendWelcomeEmailJob(mailer),
For dev-seed, register the slug with the InMemoryJobQueue so enqueue actually fires:
if (
"register" in queue &&
typeof (queue as { register?: unknown }).register === "function"
) {
(
queue as {
register: (slug: string, h: (input: unknown) => Promise<void>) => void;
}
).register("marketing-pages.send-welcome-email", async (input) => {
const wrapped = marketingPagesContainer.get<ISendWelcomeEmailJob>(
MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
);
await wrapped(input as SendWelcomeEmailInput);
});
}
Production skips this — the generated Payload task in integrations/cms/jobs/<job>.task.ts resolves the wrapped job from the per-feature container at runtime.
Edit Payload's inputSchema in the generated <job>.task.ts to match your Zod schema (Payload's inputSchema is field-config, not a TypeScript shape):
inputSchema: [
{ name: "userId", type: "text", required: true },
{ name: "email", type: "email", required: true },
],
Verify:
pnpm --filter @repo/marketing-pages lint typecheck test
Cron schedules
Job cron schedules don't live in the feature's job file or generator output — they live in core-cms's buildConfig({ jobs: { ... } }). If a job runs periodically, register it in the Payload jobs config alongside the task slug.
Anchor protocol
Six fixed anchor comments live in every feature:
| File | Anchor | Used by |
|---|---|---|
src/index.ts |
// <gen:events> |
gen event publish |
src/di/symbols.ts |
// <gen:event-handler-symbols> |
gen event consume |
src/di/symbols.ts |
// <gen:job-symbols> |
gen job |
src/di/bind-production.ts |
// <gen:event-handlers> |
gen event consume |
src/di/bind-production.ts |
// <gen:jobs> |
gen job |
src/di/bind-dev-seed.ts |
// <gen:event-handlers> |
gen event consume |
src/di/bind-dev-seed.ts |
// <gen:jobs> |
gen job |
src/integrations/cms/index.ts |
// <gen:job-tasks> |
gen event consume, gen job |
A CI guard at packages/core-eslint/anchors.test.js asserts the anchors stay present in every feature. Remove an anchor and CI fails — restore it and the test goes green.
End-to-end test reference
apps/web-next/src/__tests__/sign-up-welcome-email.test.ts exercises the full chain in dev-seed mode: bindAllDevSeed() → signUpController(...) → bus.publish → consumer handler → queue.enqueue → InMemoryJobQueue dispatch → mailer.sendWelcome recorded. Use it as a template when adding cross-feature flows.