# 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).

> **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.
