diff --git a/docs/guides/events-and-jobs.md b/docs/guides/events-and-jobs.md new file mode 100644 index 0000000..85f7e8a --- /dev/null +++ b/docs/guides/events-and-jobs.md @@ -0,0 +1,271 @@ +# 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) and [the full spec](../superpowers/specs/2026-05-08-events-and-jobs-design.md). + +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 `// ` 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 `// ` 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 => { + // ... 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 `// ` +- `src/di/bind-production.ts` — wraps the handler in span+capture, binds to the symbol, and calls `bus.subscribe(...)` at `// ` +- `src/di/bind-dev-seed.ts` — same as production, identical block +- `src/integrations/cms/index.ts` — re-exports the event-task at `// ` 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`. 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 => { + 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 (``, both ``, ``). + +**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; +export type ISendWelcomeEmailJob = ReturnType; + +export const sendWelcomeEmailJob = + (mailer: IMailerService) => + async (input: SendWelcomeEmailInput): Promise => { + 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 }).register( + "marketing-pages.send-welcome-email", + async (input) => { + const wrapped = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, + ); + await wrapped(input as SendWelcomeEmailInput); + }, + ); +} +``` + +Production skips this — the generated Payload task in `integrations/cms/jobs/.task.ts` resolves the wrapped job from the per-feature container at runtime. + +**Edit Payload's `inputSchema`** in the generated `.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 event publish` | +| `src/di/symbols.ts` | `// ` | `gen event consume` | +| `src/di/symbols.ts` | `// ` | `gen job` | +| `src/di/bind-production.ts` | `// ` | `gen event consume` | +| `src/di/bind-production.ts` | `// ` | `gen job` | +| `src/di/bind-dev-seed.ts` | `// ` | `gen event consume` | +| `src/di/bind-dev-seed.ts` | `// ` | `gen job` | +| `src/integrations/cms/index.ts` | `// ` | `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.