From cf41d11efff3f9955c0639d0ee69e439400a7537 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 10:43:53 +0200 Subject: [PATCH] docs(spec): cross-feature events and background jobs design Long-form design for a future ADR-015. Defines IEventBus (new @repo/core-events) and IJobQueue (@repo/core-shared/jobs); the bind/swap rules; the events/, events/handlers/, jobs/, integrations/cms/jobs/ folder layout; the on--.handler.ts naming rule; the span+capture sandwich extension with op: "event-handler" and op: "job"; three new ESLint rules; RecordingEventBus and RecordingJobQueue for tests; pnpm turbo gen event {publish|consume} and gen job generators plus the // anchor-comment protocol; and the existing-feature retrofit. Status: draft pending user review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-08-events-and-jobs-design.md | 653 ++++++++++++++++++ 1 file changed, 653 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-events-and-jobs-design.md diff --git a/docs/superpowers/specs/2026-05-08-events-and-jobs-design.md b/docs/superpowers/specs/2026-05-08-events-and-jobs-design.md new file mode 100644 index 0000000..68a8af6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-events-and-jobs-design.md @@ -0,0 +1,653 @@ +# Cross-feature events and background jobs — Design + +**Date:** 2026-05-08 +**Status:** Draft (pending user review) +**Supersedes:** the deferred placeholders at `docs/architecture/vertical-feature-spec.md:260` ("No `effects/`, `jobs/`, `events/` unless the feature grows them") and `:662` ("no `core-events` package yet; spec addendum v4's optional `core-events` stays deferred"). +**Companion ADR:** ADR-015 (to be created during implementation; this spec is the long-form design that ADR-015 distills). + +--- + +## 1. Context and motivation + +This template currently has no convention for two real architectural concerns: + +- **Cross-feature reactions.** Boundary rules forbid `feature → feature` imports (R6 in `AGENTS.md`). When `auth` should trigger work in `marketing-pages` (e.g. send a welcome email after sign-up), there is no documented seam. Today the only options are smell (illegal imports) or absence (the reaction can't be expressed cleanly). +- **Deferred / scheduled / retryable work.** Use cases run synchronously on the request path. There is no documented place for work that needs durability, retries, scheduling, or rate-shaping. Payload 3.x ships a built-in jobs queue (`payload.jobs`), but the template has no convention for *where* job code lives in a feature, *how* it is wired through DI and the span+capture sandwich, or *how* it is triggered. + +This spec defines both, in one document, because they share machinery (factory + sandwich, dev/prod parity via `bindAll()`, recording-test rules) and a conceptual model (typed dispatch through a core abstraction). The two concerns are kept conceptually distinct via three load-bearing rules below. + +--- + +## 2. Conceptual model and rules + +### 2.1 What events and jobs are *for* + +| | Event | Job | +|---|---|---| +| **Purpose** | A feature publishes a domain fact (e.g. `auth.user.signed-up`). Other features may react. | Deferred / scheduled / retryable work, owned and triggered by one feature. | +| **Caller** | Always the publishing feature's own use case, after it succeeds. | Any use case, controller, cron schedule, admin UI, or another job. | +| **Audience** | 0..N other features, unknown to the publisher. | One feature (itself). | +| **Transport** | `IEventBus` (in-memory in dev/test, Payload-jobs fan-out in prod). | `IJobQueue` (in-memory in dev/test, Payload jobs in prod). | +| **Why it exists** | Cross-feature decoupling — boundary rules forbid feature → feature imports, so we need a seam. | Durability, retries, scheduling, rate-shaping, getting work off the request path. | + +### 2.2 The three rules + +These are the load-bearing discipline; everything mechanical follows from them. + +- **Rule E0 — Events are for cross-feature decoupling.** If the reaction lives in the *same* feature, do not use the bus. Just call another use case. The bus is a seam, not a router. +- **Rule E1 — Event contracts are exported; handlers are private.** A feature's `events/.event.ts` is re-exported from the package root (already where contracts live per R18–R21). A feature's `events/handlers/*.handler.ts` is wired only inside that feature's own `bind-production` / `bind-dev-seed` and is never re-exported from any subpath. +- **Rule J0 — Jobs are for *deferred* work, not abstraction.** If you can do the work synchronously in the request path and it doesn't need retries, durability, or scheduling, do not make it a job. A job has operational cost (admin UI noise, retry semantics, eventual consistency) that synchronous code does not. + +These rules go verbatim into ADR-015 and into `AGENTS.md` § Per-Package Conventions. + +### 2.3 The seam (cross-feature event flow) + +``` +publisher (auth) consumer (marketing-pages) +───────────────── ────────────────────────── +events/user-signed-up.event.ts events/handlers/on-auth-user-signed-up.handler.ts + └── re-exported from @repo/auth root ←── imports contract from @repo/auth (root, allowed) + + publishes via subscribes via + IEventBus (core-events) IEventBus (core-events) + ↘ ↙ + @repo/core-events bus +``` + +The publisher never knows the consumer exists. The consumer imports only the publisher's *contract* (event name + payload schema), which already lives behind the root export. The existing ESLint boundary rules permit this — no new boundary rule is required for the cross-feature event flow. + +--- + +## 3. New and extended packages + +### 3.1 `@repo/core-events` (new) + +A new package, tagged `core` in the boundary rules, owns the bus abstraction and its two implementations. + +``` +packages/core-events/ + src/ + event-descriptor.ts # defineEvent(name, schema), EventDescriptor + event-bus.interface.ts # IEventBus + in-memory-event-bus.ts # InMemoryEventBus (dev/test) + in-memory-event-bus.test.ts + payload-jobs-event-bus.ts # PayloadJobsEventBus (prod) + payload-jobs-event-bus.test.ts + symbols.ts # CORE_EVENTS_SYMBOLS.IEventBus + index.ts # public surface + package.json + tsconfig.json + vitest.config.ts + eslint.config.js + AGENTS.md +``` + +`core-events` is intentionally a separate package, not folded into `core-shared`, because: + +- `core-shared` owns *primitives* (Zod, env, instrumentation, tRPC init). `core-events` owns *infrastructure*. +- The Payload-jobs-backed bus implementation depends on `payload`. `core-shared` does not. +- A future bus replacement (Kafka, NATS, etc.) is one-package swap — easy to spot and easy to test. + +Boundary tag: **core**. Allowed dependents: `feature`, `core`, `core-composition`, `app` (everything that may already depend on `core`). Allowed dependencies: `core-shared`, `tooling`. + +### 3.2 `@repo/core-shared/jobs/` (extension) + +`core-shared` gains a `jobs/` subdirectory with the queue abstraction: + +``` +packages/core-shared/src/jobs/ + job-queue.interface.ts # IJobQueue + in-memory-job-queue.ts # InMemoryJobQueue + payload-job-queue.ts # PayloadJobQueue + symbols.ts # CORE_SHARED_JOBS_SYMBOLS.IJobQueue + index.ts # exported via @repo/core-shared/jobs subpath +``` + +A new public subpath export `@repo/core-shared/jobs` is added (`./jobs` in `package.json` exports map). This keeps the queue addressable from features without dragging Payload into `core-shared`'s root import surface. + +Boundary effect: none. `core-shared` was already `core`-tagged; the new subpath is a cosmetic extension of the existing public API. + +### 3.3 Interfaces — exact shapes + +```ts +// packages/core-events/src/event-descriptor.ts +import type { z } from "zod"; + +export type EventDescriptor = { + readonly name: TName; + readonly schema: TSchema; +}; + +export function defineEvent( + name: TName, + schema: TSchema, +): EventDescriptor { + return { name, schema }; +} +``` + +```ts +// packages/core-events/src/event-bus.interface.ts +import type { z } from "zod"; +import type { EventDescriptor } from "./event-descriptor"; + +export type EventHandler = (event: T) => Promise; + +export interface IEventBus { + publish( + descriptor: EventDescriptor>, + payload: T, + ): Promise; + + subscribe( + descriptor: EventDescriptor>, + handler: EventHandler, + ): void; +} +``` + +```ts +// packages/core-shared/src/jobs/job-queue.interface.ts +export interface IJobQueue { + enqueue( + taskSlug: string, + input: T, + options?: { runAt?: Date }, + ): Promise<{ jobId: string }>; +} +``` + +The bus is generic over the schema's inferred type, so `bus.publish(userSignedUpEvent, payload)` infers `payload`'s type from `userSignedUpEvent.schema` and rejects mismatches at compile time. + +The queue is intentionally minimal — a single `enqueue` method with optional `runAt`. Cron schedules live in the Payload jobs config (in `core-cms`'s `buildConfig`), not in this interface. + +--- + +## 4. Per-feature folder layout + +A feature that uses events and/or jobs has these additional directories (all optional; absent if unused): + +``` +packages//src/ + events/ + .event.ts # publisher: contract (Zod + defineEvent) + .event.test.ts + handlers/ + on--.handler.ts # consumer: factory + on--.handler.test.ts + jobs/ + .job.ts # factory + Zod schema + .job.test.ts + integrations/cms/jobs/ + .task.ts # Payload TaskConfig glue + index.ts # barrel for core-cms aggregation +``` + +Naming rules — keep file names and wire names consistent but distinct: + +- **Event contract file name:** `.event.ts`, all-kebab (e.g. `user-signed-up.event.ts`). +- **Event name on the wire:** `.` where `` is past-tense, dot-separated (e.g. `auth.user.signed-up`). The file `user-signed-up.event.ts` defines the event named `auth.user.signed-up`. +- **Event handler file name:** `on--.handler.ts` where `` matches the publisher's contract file (e.g. `on-auth-user-signed-up.handler.ts` consumes `auth.user.signed-up`). Publisher prefix is **mandatory** to keep handler names unique when one consumer subscribes to events from multiple publishers. +- **Job file name:** `.job.ts`, verb-noun kebab (e.g. `send-welcome-email.job.ts`). +- **Job slug on the wire:** `.` (e.g. `marketing-pages.send-welcome-email`). + +--- + +## 5. File shapes + +### 5.1 Event contract (publisher) + +```ts +// packages/auth/src/events/user-signed-up.event.ts +import { z } from "zod"; +import { defineEvent } from "@repo/core-events"; + +export const userSignedUpEventSchema = z + .object({ + userId: z.string(), + email: z.string().email(), + signedUpAt: z.string().datetime(), + }) + .strict(); + +export type UserSignedUpEvent = z.infer; + +export const userSignedUpEvent = defineEvent( + "auth.user.signed-up", + userSignedUpEventSchema, +); +``` + +Re-exported from `packages/auth/src/index.ts`: + +```ts +// +export { + userSignedUpEvent, + userSignedUpEventSchema, + type UserSignedUpEvent, +} from "./events/user-signed-up.event"; +``` + +The `// ` anchor comment is part of the generator protocol (§ 9). + +### 5.2 Publisher use case + +```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 = + (users: IUsersRepository, bus: IEventBus) => + async (input: SignUpInput): Promise => { + const user = await users.create(input); + await bus.publish(userSignedUpEvent, { + userId: user.id, + email: user.email, + signedUpAt: new Date().toISOString(), + }); + return signUpOutputSchema.parse(user); + }; +``` + +The use-case factory gains a `bus: IEventBus` constructor parameter. The publisher always `await`s `publish`. What the bus does with the await is the bus's call (in-memory: synchronous fan-out; Payload-jobs: returns after enqueue, not after delivery). The publisher's contract is "I told the bus." + +DI binding for this use case is updated to inject the bus: + +```ts +bind(AUTH_SYMBOLS.ISignUpUseCase) + .toDynamicValue((ctx) => + withSpan( + tracer, + { name: "auth.signUp", op: "use-case" }, + withCapture( + logger, + { feature: "auth", layer: "use-case", name: "auth.signUp" }, + signUpUseCase( + ctx.container.get(AUTH_SYMBOLS.IUsersRepository), + ctx.container.get(CORE_EVENTS_SYMBOLS.IEventBus), + ), + ), + ), + ); +``` + +### 5.3 Event handler (consumer) + +```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 type IOnAuthUserSignedUpHandler = ReturnType; + +export const onAuthUserSignedUpHandler = + (queue: IJobQueue) => + async (event: UserSignedUpEvent): Promise => { + await queue.enqueue("marketing-pages.send-welcome-email", { + userId: event.userId, + email: event.email, + }); + }; +``` + +The handler imports `UserSignedUpEvent` from `@repo/auth` (root, allowed). It is a factory of shape `(deps) => async (event: T) => Promise` — same shape as a use case, but with `void` return and no presenter. + +### 5.4 Subscription (consumer's `bind-*`) + +```ts +// packages/marketing-pages/src/di/bind-production.ts (excerpt) +export function bindProductionMarketingPages(config: SanitizedConfig): void { + const container = getMarketingPagesContainer(config); + // ... repository bindings (existing) ... + + const tracer = container.get(INSTRUMENTATION_SYMBOLS.TRACER); + const logger = container.get(INSTRUMENTATION_SYMBOLS.LOGGER); + const bus = container.get(CORE_EVENTS_SYMBOLS.IEventBus); + const queue = container.get(CORE_SHARED_JOBS_SYMBOLS.IJobQueue); + + // + const onAuthUserSignedUp = withSpan( + tracer, + { name: "marketing-pages.onAuthUserSignedUp", op: "event-handler" }, + withCapture( + logger, + { + feature: "marketing-pages", + layer: "event-handler", + name: "marketing-pages.onAuthUserSignedUp", + }, + onAuthUserSignedUpHandler(queue), + ), + ); + bus.subscribe(userSignedUpEvent, onAuthUserSignedUp); +} +``` + +The handler is wrapped in the **same span+capture sandwich** as use cases (R41–R44 from `docs/decisions/adr-014-instrumentation-sentry.md`), with two new tag values: `op: "event-handler"`, `layer: "event-handler"`. The `// ` anchor marks where `gen event consume` inserts new blocks. + +**Note on `bind-*` side effects.** Today the `bindProduction` / `bindDevSeed` functions are pure container-modification: they only call `container.bind(...)`. With this spec, they additionally call `bus.subscribe(...)` for each event handler the feature consumes. This is still boot-time-only wiring (the binders run once during `bindAll()`), but it is a small departure worth flagging. The binders remain idempotent: calling `bindAll()` twice is incorrect today and remains incorrect after this change. + +### 5.5 Job + +```ts +// packages/marketing-pages/src/jobs/send-welcome-email.job.ts +// Illustrative — assumes a future mailer service exists in marketing-pages. +// The shipped end-to-end test in § 13 substitutes a recording mailer. +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); // queue payload arrives as unknown + await mailer.sendWelcome(input.userId, input.email); + }; +``` + +Differences from a use case: + +- **Boundary parse at the top** of the body. The queue payload arrives as `unknown` (from a serialized JSON column), so the job parses it itself — same role `safeParse` plays in a controller. +- **No output schema and no presenter.** Jobs are fire-and-forget; what they return goes to the queue's bookkeeping, not to a caller. +- **Same factory + sandwich wiring** at DI bind time. + +### 5.6 Payload task glue + +```ts +// packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts +import type { TaskConfig } from "payload"; +import { getMarketingPagesContainer } from "../../../di/container"; +import { MARKETING_PAGES_SYMBOLS } from "../../../di/symbols"; +import type { ISendWelcomeEmailJob } from "../../../jobs/send-welcome-email.job"; + +export const sendWelcomeEmailTask: TaskConfig<"marketing-pages.send-welcome-email"> = { + slug: "marketing-pages.send-welcome-email", + inputSchema: [ + /* Payload field config matching the Zod schema */ + ], + retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } }, + handler: async ({ req, input }) => { + const handler = getMarketingPagesContainer(req.payload.config).get( + MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, + ); + await handler(input); + return { output: {} }; + }, +}; +``` + +Re-exported from `packages/marketing-pages/src/integrations/cms/index.ts` alongside collections, behind the `// ` anchor. `core-cms` then aggregates them: + +```ts +// packages/core-cms/src/index.ts (excerpt) +import { + pagesCollection, + siteSettingsGlobal, + sendWelcomeEmailTask, +} from "@repo/marketing-pages/cms"; + +export default buildConfig({ + collections: [pagesCollection /* , ... */], + globals: [siteSettingsGlobal /* , ... */], + jobs: { tasks: [sendWelcomeEmailTask /* , ... */] }, +}); +``` + +No new subpath, no new boundary rule. `./cms` does the same job for tasks that it already does for collections. + +--- + +## 6. Bus and queue implementations + +### 6.1 Two bus implementations, swapped by `bindAll()` + +| Implementation | Behavior | Default for | +|---|---|---| +| `InMemoryEventBus` | Validates payload via `descriptor.schema.parse`, then `await Promise.allSettled(handlers)`. Publisher's `publish` resolves after all handlers settle. Per-handler errors are captured by each handler's own sandwich (`withCapture`) and **swallowed by the bus** (publisher never sees them). One config knob: `failFast: boolean` (default `false`) for tests that want to assert handler failures bubble. | dev, tests, `pnpm dev` | +| `PayloadJobsEventBus` | Validates payload, then enqueues one Payload task per subscriber (`__events..`). `publish` returns after enqueue, not after delivery. Retries + durability come from Payload. | production | + +**Decision: swallow vs failFast in dev/test.** Swallow by default because: + +1. Mirrors production semantics — `PayloadJobsEventBus` is async; a publisher in prod never sees a downstream handler error, so dev should match. +2. Per-handler sandwiches already capture and report errors via `logger.captureException`. The publisher does not need to re-handle them. +3. Tests that *want* to assert handler failure pass `failFast: true` to the bus constructor — explicit opt-in. + +### 6.2 Two queue implementations, swapped by `bindAll()` + +| Implementation | Behavior | Default for | +|---|---|---| +| `InMemoryJobQueue` | Resolves the task's handler from the local container and runs it on `setImmediate`. Honors `runAt` via `setTimeout`. Returns a synthetic `jobId`. | dev, tests | +| `PayloadJobQueue` | Wraps `payload.jobs.queue({ task, input, waitUntil: options?.runAt })`. Returns the Payload job id. | production | + +### 6.3 Selection rule (extends existing `bindAll()`) + +The existing app-level `bindAll()` dispatcher (in `apps/web-next/src/server/bind-production.ts`) gains a new bus/queue swap step that runs **before** any per-feature binder (so per-feature binders can `container.get(...)` without ambiguity). It is independent of, and runs alongside, the existing Rule 0 (DSN → Sentry vs Noop instrumentation). Both the bus and the queue follow the same three-mode rule the repositories already use: + +- `USE_DEV_SEED === "true"` → `InMemoryEventBus` + `InMemoryJobQueue` (any `NODE_ENV`) +- `NODE_ENV === "production"` → `PayloadJobsEventBus` + `PayloadJobQueue` +- otherwise → `InMemoryEventBus` + `InMemoryJobQueue` (developer default) + +This means `pnpm dev` boots without Payload and events/jobs still work end-to-end, and `USE_DEV_SEED=true pnpm start` (or any prod-build smoke test) works without a Payload connection. Same trick that makes the existing repository binding work today. + +### 6.4 Why `IJobQueue` lives in `core-shared` and `IEventBus` lives in `core-events` + +- **`IJobQueue` works standalone.** A feature can have jobs without ever publishing or subscribing to events. Putting the queue in `core-shared` keeps simple cases simple — features only depend on `core-events` if they actually use the bus. +- **The bus depends on the queue (in production).** `PayloadJobsEventBus` enqueues subscriber tasks via `IJobQueue`. `core-events` therefore depends on `core-shared` — the existing direction (`core` may depend on `core`). +- **A future bus replacement does not affect the queue.** If `core-events` swaps to a non-Payload bus (Kafka, NATS), `IJobQueue` is unaffected. + +--- + +## 7. Boundary rules and ESLint + +Three new ESLint rules in `@repo/core-eslint`: + +1. **No handler re-exports.** Files matching `**/events/handlers/*.handler.ts` may not appear in any `export from` or `export *` statement in any `index.ts` or barrel file. Custom rule, ~20 LOC. Enforces Rule E1. +2. **No direct `payload.jobs` outside the integration layer.** `payload.jobs` member access is forbidden outside `**/integrations/cms/jobs/**` and `packages/core-shared/src/jobs/**`. Use cases / controllers / repositories / event handlers must use `IJobQueue`. Custom rule, ~15 LOC. +3. **R40 allowlist confirmation.** The existing R40 rule (no `@sentry/*` in feature code) is verified to cover `events/`, `events/handlers/`, and `jobs/` — they are *not* added to the allowlist. + +The existing five-tag boundary system already covers everything else: + +- `feature → core-events` (allowed: feature → core) +- `feature → core-shared/jobs` (allowed: feature → core) +- `feature consumer → @repo/` for event contracts (allowed only for the root subpath, which is contracts-only per R18–R21) +- `core-events → core-shared` (allowed: core → core) +- `core-events` cannot import from any feature (allowed: core forbids feature) + +No new tag, no new package-level rule. Only the three custom-rule additions above. + +--- + +## 8. Testing + +`@repo/core-testing/instrumentation` gains two recordings: + +```ts +// packages/core-testing/src/instrumentation/recording-event-bus.ts +export class RecordingEventBus implements IEventBus { + readonly published: { name: string; payload: unknown }[] = []; + private handlers = new Map[]>(); + + async publish(d: EventDescriptor>, payload: T): Promise { + d.schema.parse(payload); + this.published.push({ name: d.name, payload }); + for (const h of this.handlers.get(d.name) ?? []) await h(payload); + } + + subscribe(d: EventDescriptor>, h: EventHandler): void { + const arr = this.handlers.get(d.name) ?? []; + arr.push(h as EventHandler); + this.handlers.set(d.name, arr); + } +} +``` + +```ts +// packages/core-testing/src/instrumentation/recording-job-queue.ts +export class RecordingJobQueue implements IJobQueue { + readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = []; + + async enqueue(taskSlug: string, input: T, options?: { runAt?: Date }) { + this.enqueued.push({ taskSlug, input, options }); + return { jobId: `recording-${this.enqueued.length}` }; + } +} +``` + +| Test | Strategy | +|---|---| +| Publisher use case publishes the right event | Inject `RecordingEventBus`, assert `bus.published[0]` matches. | +| Consumer handler does its job | Call factory with mocks, pass an event payload directly. No bus involved. | +| Subscription is wired | One per-feature integration test: instantiate `InMemoryEventBus`, run `bindDevSeed(bus, queue)`, publish, assert handler side effect. | +| Job factory does its job | Identical to a use-case test. | +| Use case enqueues a job | Inject `RecordingJobQueue`, assert `queue.enqueued[0]` matches. | +| Schema enforcement at the bus | Pass an invalid payload; assert `publish` throws `ZodError`, captured by the publisher's `withCapture`. | + +The `bindDevSeed` signature gains optional `bus` and `queue` parameters (defaulted to fresh in-memory instances) so feature integration tests can substitute recordings. + +--- + +## 9. Generators + +Two new Plop generators in `turbo/generators/config.ts`, alongside the existing `feature` generator. Both **augment an existing feature package** (rather than creating a new one) using a mix of Plop `add` and `modify` actions. + +### 9.1 The anchor-comment protocol + +A new convention introduced by this spec: six stable single-line anchor comments, each marking an insertion point for a generator. Pre-existing features and the `feature` generator template are updated to include the anchors as no-op comments, so all features have them from day one regardless of whether they currently use events or jobs. + +| Anchor | File | Inserted by | +|---|---|---| +| `// ` | `src/index.ts` | `gen event publish` | +| `// ` | `src/di/symbols.ts` | `gen event consume` | +| `// ` | `src/di/bind-production.ts`, `bind-dev-seed.ts` | `gen event consume` | +| `// ` | `src/integrations/cms/index.ts` | `gen job` | +| `// ` | `src/di/symbols.ts` | `gen job` | +| `// ` | `src/di/bind-production.ts`, `bind-dev-seed.ts` | `gen job` | + +Files without the required anchor cause the generator to abort with a clear `missing // anchor in ` error before writing partial output. + +### 9.2 `pnpm turbo gen event` + +A single generator with a `mode` prompt. Prompts and validation: + +| Prompt | Validation | Used for | +|---|---|---| +| `mode` | `publish` \| `consume` | Branch logic | +| `feature` | kebab-case, `packages//src/` must exist | Where the file is generated | +| `event` | dotted-kebab past tense (`user.signed-up`) | Event slug — full name becomes `.` (publish) or `.` (consume) | +| `publisher` (consume only) | kebab-case, `packages//src/events/.event.ts` must exist | Import path for the contract | + +**Publish mode** emits: + +- `packages//src/events/.event.ts` (Zod schema + `defineEvent`; schema body is a `z.object({}).strict()` stub) +- `packages//src/events/.event.test.ts` +- `modify` `packages//src/index.ts` — re-export the contract behind `// ` + +It does **not** modify any use case. Wiring `bus: IEventBus` into a use-case factory is a manual edit because the user picks which use case publishes; the printed next-steps block walks through that edit. + +**Consume mode** emits: + +- `packages//src/events/handlers/on--.handler.ts` +- `packages//src/events/handlers/on--.handler.test.ts` +- `modify` `packages//src/di/symbols.ts` — add `IOn<...>Handler` symbol behind `// ` +- `modify` `packages//src/di/bind-production.ts` and `bind-dev-seed.ts` — add `bus.subscribe(...)` block with the span+capture sandwich behind `// ` + +### 9.3 `pnpm turbo gen job` + +Prompts: + +| Prompt | Validation | Used for | +|---|---|---| +| `feature` | kebab-case, must exist | Where the job lives | +| `job` | verb-noun kebab (`send-welcome-email`) | Slug becomes `.` | +| `inputShape` | `void` \| `typed` | `z.object({}).strict()` (void) or stub with one example field (typed) | + +Emits: + +- `packages//src/jobs/.job.ts` +- `packages//src/jobs/.job.test.ts` +- `packages//src/integrations/cms/jobs/.task.ts` +- `modify` `packages//src/integrations/cms/index.ts` — re-export task (`// `) +- `modify` `packages//src/di/symbols.ts` — add job symbol (`// `) +- `modify` `packages//src/di/bind-production.ts` and `bind-dev-seed.ts` — add factory binding with `op: "job"` sandwich (`// `) + +### 9.4 Idempotency / failure modes + +- **File collisions** (`add` actions) fail loudly — Plop's default. Re-running with the same slug gets a clear error. +- **Modify actions** insert at the anchor on every run. Duplicate bindings produce a TypeScript error at the next `pnpm typecheck`. Acceptable tradeoff: simple regex inserts, no AST manipulation, errors are obvious. +- **Validation order:** existence checks (feature directory, anchor presence in target files, publisher contract for consume mode) run during the `prompts` phase so the generator aborts before writing partial output. + +### 9.5 What the generators do NOT do + +Stays out of scope (printed in the next-steps block instead): + +- Modifying the publisher use case to inject the bus (publish mode). +- Wiring custom dependencies into the handler / job factory (user edits the constructor signature manually). +- Filling in the Zod schema body or Payload `inputSchema` field config beyond a stub. +- Cron schedule definition (lives in `core-cms`'s `buildConfig`, manual edit). +- Aggregator wiring (the existing `bindAll()` in `apps/web-next/src/server/bind-production.ts` already handles per-feature binders; no per-event/per-job edits needed there). + +### 9.6 Existing-feature retrofit + +The five existing features (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) and the `feature` generator template all gain the six anchor comments as no-op single-line comments in the appropriate files. Pure additive comment-only edits — no behavioral change. One implementation-plan task per feature plus one for the template. + +--- + +## 10. Documentation impact + +Files updated as part of this spec's implementation: + +- **`AGENTS.md`** — new section under Per-Package Conventions for events and jobs (rules E0/E1/J0, folder layout, sandwich tags, `bindAll` rule, lint summary). New row in the Specification & Guides table. +- **`CLAUDE.md`** — Quick Start gains `pnpm turbo gen event` / `pnpm turbo gen job`. Read First gains a link to `docs/guides/events-and-jobs.md` (new). Key Conventions gains short bullets for E0, E1, J0. +- **`docs/guides/events-and-jobs.md`** — new long-form guide covering "publish an event", "consume an event", "add a job", with step-by-step generator-plus-manual-wiring walkthroughs. +- **`docs/guides/scaffolding-a-feature.md`** — two new sections at the bottom referencing the new generators and the events/jobs guide. +- **`docs/decisions/adr-015-events-and-jobs.md`** — new ADR distilling this spec's decisions and rules. +- **`docs/architecture/vertical-feature-spec.md`** — the deferred lines at `:260` and `:662` get updated to point at this spec instead of saying "deferred". + +--- + +## 11. Out of scope (deferred until needed) + +Each gets a one-line "deferred until needed; will be its own ADR" note in ADR-015: + +- **Multi-task workflows.** Payload's `WorkflowConfig` (composing tasks with branching/retry logic). Defer until one feature has a real multi-step workflow. +- **Saga / orchestration.** Cross-feature multi-step processes with compensating actions. Defer until a real saga appears. +- **Exactly-once delivery semantics.** Current design is at-least-once for the Payload-jobs bus (each subscriber gets its own task; retries possible). If at-most-once or exactly-once is ever required, that is a new ADR with a new bus implementation. +- **Event versioning.** Schema evolution strategy (v1 / v2 event shapes, parallel publishing, consumer migration). Defer until first breaking-change need. +- **Subscriber dynamic discovery.** Currently `bus.subscribe` is called at app boot inside each consumer's `bind-production`. A feature added at runtime (plugin model) cannot subscribe. Defer. +- **`pnpm turbo gen feature --with-events --with-jobs` flags.** The Phase-1 `feature` generator stays single-purpose. If repeated patterns emerge across features, revisit. + +--- + +## 12. Decisions locked at this stage + +These are the irreversible-feeling design calls — flagged here so reviewers spot any disagreement before implementation begins: + +1. **Both events and jobs in one ADR (ADR-015), not split.** They share machinery (factory + sandwich, `bindAll` rule, recording-test pattern). Splitting would force cross-references on every page. +2. **Rule E0: no in-feature events.** The bus is for cross-feature decoupling only. In-feature reactions are direct use-case calls. +3. **`@repo/core-events` is a new package, not a fold-in to `core-shared`.** Reasoning in § 3.1. +4. **`IJobQueue` lives in `core-shared/jobs/`.** Reasoning in § 6.4. +5. **In-memory bus swallows handler errors by default.** Reasoning in § 6.1. +6. **Handler files use `on--.handler.ts`.** Publisher prefix is mandatory. +7. **Generators for event (publish/consume) and job are in scope.** Anchor-comment protocol is the convention. +8. **Existing-feature retrofit is part of the implementation plan.** All five features gain the six anchor comments as no-op edits. + +--- + +## 13. Success criteria + +The implementation is complete when: + +- `pnpm turbo gen event publish` and `pnpm turbo gen event consume` run cleanly against `auth` and `marketing-pages`, producing files that pass lint + typecheck + tests on first generation. +- `pnpm turbo gen job` runs cleanly against `marketing-pages`, producing files that pass lint + typecheck + tests on first generation. +- A single end-to-end test in `apps/web-next` proves the cross-feature flow: a sign-up triggers a `marketing-pages` job that asserts a side effect against a recording mailer. +- `bindAll()` correctly swaps the bus and queue between in-memory and Payload-backed based on `USE_DEV_SEED` / `NODE_ENV`, verified by a feature integration test in each mode. +- All five existing features have the six anchor comments (verified by a tiny CI grep test). +- `AGENTS.md`, `CLAUDE.md`, `docs/guides/events-and-jobs.md`, `docs/guides/scaffolding-a-feature.md`, and `docs/decisions/adr-015-events-and-jobs.md` are written and cross-linked. +- All three new ESLint rules pass against the touched packages and reject deliberately-broken fixtures in `core-eslint`'s test suite.