Files
agentic-dev/docs/guides/events-and-jobs.md
Danijel Martinek 89d47cce5c docs: strip dead docs/superpowers/ refs across ADRs + guides + glossary
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>
2026-05-13 17:00:11 +02:00

288 lines
10 KiB
Markdown

# Events and Jobs
Walkthrough for adding cross-feature events and background jobs to a feature. For the architectural rationale, see [ADR-015](../decisions/adr-015-events-and-jobs.md).
> **Prerequisite — `@repo/core-events` is optional.**
> The event bus (`IEventBus`, `InMemoryEventBus`, `PayloadJobsEventBus`) lives
> in `@repo/core-events`, which ships as a scaffoldable package rather than a
> permanent fixture. If `packages/core-events/` does not exist in your repo,
> run `pnpm turbo gen core-package events` first, then wire the bus into
> `apps/web-next/src/server/bind-production.ts` as 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.
```bash
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:
```bash
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 in `packages/auth/src/index.ts`
Then **fill in the schema** with the actual fields the event carries:
```ts
// 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:
```ts
// 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:
```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),
),
);
```
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:**
```bash
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`):
```bash
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` + test
- `packages/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 calls `bus.subscribe(...)` at `// <gen:event-handlers>`
- `src/di/bind-dev-seed.ts` — same as production, identical block
- `src/integrations/cms/index.ts` — re-exports the event-task at `// <gen:job-tasks>` so `core-cms` aggregates 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):
```ts
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`:
```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:
```ts
// 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`:
```ts
onAuthUserSignedUpHandler(queue),
```
**Verify:**
```bash
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
```bash
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` + test
- `packages/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:
```ts
// 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:
```ts
sendWelcomeEmailJob(mailer),
```
**For dev-seed, register the slug** with the `InMemoryJobQueue` so `enqueue` actually fires:
```ts
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):
```ts
inputSchema: [
{ name: "userId", type: "text", required: true },
{ name: "email", type: "email", required: true },
],
```
**Verify:**
```bash
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.