Skip to content

Commit b37cfdf

Browse files
committed
feat(outbox): add outbox event relay and handler registry
1 parent 8dd4e81 commit b37cfdf

25 files changed

Lines changed: 1619 additions & 165 deletions

docker-compose.yml

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
# ============================================================================
22
# Learnault API — Local Development Stack
33
#
4-
# One command starts a healthy stack (API + wallet worker + PostgreSQL + Redis):
4+
# One command starts a healthy stack (API + PostgreSQL + Redis):
55
# docker compose up -d --build
66
#
77
# The API container applies migrations and seeds deterministic fixtures on
8-
# boot (see docker/entrypoint-dev-api.sh). The worker drains the idempotent
9-
# wallet-provisioning outbox (see src/workers/wallet-provisioning.worker.ts).
8+
# boot (see docker/entrypoint-dev-api.sh).
109
#
1110
# Useful commands:
1211
# docker compose ps → service status + health
@@ -95,28 +94,6 @@ services:
9594
start_period: 20s
9695
stop_grace_period: 30s
9796

98-
# --------------------------------------------------------------------------
99-
# Worker — drains the wallet-provisioning outbox
100-
# --------------------------------------------------------------------------
101-
worker:
102-
build:
103-
context: .
104-
dockerfile: docker/Dockerfile.dev
105-
restart: unless-stopped
106-
depends_on:
107-
db:
108-
condition: service_healthy
109-
environment:
110-
NODE_ENV: development
111-
DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public
112-
RUN_MIGRATIONS: 'true'
113-
WORKER_POLL_INTERVAL_MS: ${WORKER_POLL_INTERVAL_MS:-5000}
114-
volumes:
115-
- .:/app
116-
- /app/node_modules
117-
command: ['./docker/entrypoint-dev-worker.sh']
118-
stop_grace_period: 30s
119-
12097
scheduler:
12198
build:
12299
context: .

docker/entrypoint-dev-worker.sh

Lines changed: 0 additions & 18 deletions
This file was deleted.

docs/domains/REQUEST_AND_EVENT_FLOWS.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This document maps the key request flows and domain event propagation patterns a
1414
6. [Referral Application Flow](#referral-application-flow)
1515
7. [Withdrawal Flow](#withdrawal-flow)
1616
8. [Notification Delivery Flow](#notification-delivery-flow)
17+
9. [Subscribing to Domain Events](#subscribing-to-domain-events)
1718

1819
---
1920

@@ -508,3 +509,121 @@ Target state: Event-driven communication via domain events
508509
| Credentials | Blockchain Infra | Service Call | On-chain storage |
509510
| Credentials | Notifications | Domain Event | Credential notification |
510511
| All Domains | Shared Kernel | Direct Import | Config, errors, middleware, utils |
512+
513+
---
514+
515+
## Subscribing to Domain Events
516+
517+
A new domain event needs a registered handler, not a new worker process. The
518+
outbox relay leases every pending event and dispatches it by `eventType` to the
519+
handlers registered for that type.
520+
521+
### 1. Declare the event schema
522+
523+
Add the payload schema in `src/lib/transactions/event-schema.ts`. A handler for
524+
an event type with no schema is rejected at startup.
525+
526+
```ts
527+
registry.register({
528+
version: 1,
529+
eventType: 'ModuleCompleted',
530+
validate: async (payload) => {
531+
await z.object({
532+
completionId: z.string().uuid(),
533+
userId: z.string().uuid(),
534+
}).parseAsync(payload)
535+
},
536+
})
537+
```
538+
539+
### 2. Emit the event in the same transaction as the domain write
540+
541+
```ts
542+
await prisma.$transaction(async (tx) => {
543+
const completion = await tx.completion.create({ data: ... })
544+
545+
await createOutboxService(prisma).createEvent(tx, {
546+
aggregateId: completion.id,
547+
aggregateType: 'Completion',
548+
eventType: 'ModuleCompleted',
549+
eventVersion: 1,
550+
payload: { completionId: completion.id, userId },
551+
source: 'api.module.complete',
552+
})
553+
554+
return completion
555+
})
556+
```
557+
558+
If the transaction rolls back the event disappears with it, so an event can
559+
never describe a write that did not happen.
560+
561+
### 3. Write the handler
562+
563+
```ts
564+
export class RewardOnModuleCompleted implements OutboxEventHandler {
565+
readonly name = 'rewards.on-module-completed'
566+
readonly eventType = 'ModuleCompleted'
567+
readonly eventVersion = 1
568+
readonly maxAttempts = 5
569+
570+
async handle(ctx: OutboxEventHandlerContext) {
571+
const payload = ctx.payload as ModuleCompletedPayload
572+
await rewardService.grant(payload.userId, payload.completionId)
573+
574+
return { idempotencyKey: `${ctx.eventId}:${this.name}` }
575+
}
576+
}
577+
```
578+
579+
`name` must be unique across the whole registry — it becomes `JobAttempt.jobType`.
580+
581+
**Handlers must be idempotent.** A handler can run more than once for the same
582+
event: after a crash mid-lease, or after an operator replays a dead-lettered
583+
event. Make the side effect an upsert, or guard it on a key derived from
584+
`ctx.eventId`.
585+
586+
Throwing from `handle()` schedules a retry with exponential backoff. Returning
587+
normally completes the attempt.
588+
589+
### 4. Register it
590+
591+
Add the handler in `src/jobs/handler-registrations.ts`, and add its event type
592+
to `EMITTED_EVENT_TYPES` if the application emits it:
593+
594+
```ts
595+
registry.register(new RewardOnModuleCompleted())
596+
```
597+
598+
`registerOutboxHandlers()` throws at startup on a duplicate handler name, on a
599+
handler whose event type has no schema, and on an emitted event type with no
600+
handler — so a missing subscription fails loudly instead of leaving rows PENDING
601+
forever.
602+
603+
### What the relay guarantees
604+
605+
- One `JobAttempt` per (event, handler). Several handlers may subscribe to the
606+
same event type and each is tracked separately.
607+
- An event becomes `PUBLISHED` only once **every** handler for its type has
608+
completed. One failing handler holds the event back without blocking others.
609+
- A handler that exhausts `maxAttempts` dead-letters its own job and the event,
610+
leaving every other event type unaffected.
611+
- An event with no registered handler is dead-lettered immediately and logged at
612+
error level, rather than sitting `PENDING` unnoticed.
613+
614+
### Operating dead letters
615+
616+
```bash
617+
pnpm outbox:replay list # dead-lettered events and last error
618+
pnpm outbox:replay replay <eventId> ... # reset to PENDING for another pass
619+
```
620+
621+
Replay resets the dead-lettered `JobAttempt` rows and returns the event to
622+
`PENDING`; the relay picks it up on its next tick. Completed handlers are not
623+
re-run, and idempotent handlers make a repeated run harmless.
624+
625+
### Where it runs
626+
627+
The relay is a queue on the scheduled job runner
628+
(`src/workers/scheduler.worker.ts`), registered as `outbox-relay`. There is no
629+
per-domain worker process: adding a domain event means adding a handler.

learnault-api@0.1.0

Whitespace-only changes.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@
3838
"seed:reset": "tsx prisma/seed.ts --reset",
3939
"db:seed": "npm run seed",
4040
"db:studio": "prisma studio",
41-
"worker:dev": "tsx src/workers/wallet-provisioning.worker.ts",
4241
"scheduler": "node dist/workers/scheduler.worker.js",
4342
"scheduler:dev": "tsx src/workers/scheduler.worker.ts",
43+
"outbox:replay": "tsx src/workers/outbox-replay.ts",
44+
"relay:verify": "bash scripts/relay-verification.sh",
4445
"stack:validate": "docker compose config --quiet",
4546
"stack:up": "docker compose up -d --build",
4647
"stack:down": "docker compose down",
4748
"stack:reset": "docker compose down -v",
48-
"stack:logs": "docker compose logs -f api worker scheduler",
49+
"stack:logs": "docker compose logs -f api scheduler",
4950
"stack:smoke": "bash scripts/stack-smoke-test.sh",
5051
"scheduler:verify": "bash scripts/scheduler-verification.sh"
5152
},

scripts/relay-verification.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
command -v docker >/dev/null 2>&1 || export PATH="$PATH:/c/Program Files/Docker/Docker/resources/bin"
5+
6+
PGUSER_="${POSTGRES_USER:-learnault}"
7+
PGDB_="${POSTGRES_DB:-learnault_dev}"
8+
PGPORT_="${POSTGRES_PORT:-5433}"
9+
10+
export DATABASE_URL="${DATABASE_URL:-postgresql://${PGUSER_}:learnault@localhost:${PGPORT_}/${PGDB_}?schema=public}"
11+
export NODE_ENV="${NODE_ENV:-production}"
12+
export LOG_LEVEL="${LOG_LEVEL:-info}"
13+
14+
echo "==> Starting PostgreSQL"
15+
docker compose up -d db >/dev/null 2>&1
16+
until docker compose exec -T db pg_isready -U "$PGUSER_" -d "$PGDB_" >/dev/null 2>&1; do sleep 1; done
17+
18+
HAS_OUTBOX="$(docker compose exec -T db psql -U "$PGUSER_" -d "$PGDB_" -qAt \
19+
-c "SELECT to_regclass('public.outbox_events');" | tr -d '\r')"
20+
21+
if [ -z "$HAS_OUTBOX" ]; then
22+
echo "==> Syncing schema"
23+
npx prisma db push --accept-data-loss >/dev/null 2>&1
24+
fi
25+
26+
exec npx tsx scripts/relay-verification.ts

0 commit comments

Comments
 (0)