@thejob/notify
v1.2.0
Published
Notification pipeline contract + client for The Job: domain services emit facts over Redis Streams, the notification-service turns them into mail.
Readme
@thejob/notify
The notification pipeline package for The Job: the event contract (types, a validator, a routing table), the high-level client domain services publish through, and the Redis Streams producer/consumer that carry the events.
One package, four entry points. Import only the subpath you need, and nothing
else reaches your dependency graph, which is why the client is not published
separately. Domain services want /client; the
notification-service root uses /consumer.
What's in it
| Import | Contents |
| --- | --- |
| @thejob/notify | NOTIFY_EVENTS_STREAM, NotifyEvent, NotifyEventType, parseNotifyEvent, NOTIFY_ROUTES, routeFor |
| @thejob/notify/client | NotificationClient, notifyKeys — see CLIENT.md |
| @thejob/notify/producer | createNotifyEventsProducer |
| @thejob/notify/consumer | createNotifyEventsConsumer |
/client vs /producer: the client takes a domain fact and derives the event
type and idempotency key for you; the producer takes both by hand. Prefer the
client unless you are emitting an event it has no method for.
The root entry is zero-dependency (types + a structural validator + the routing table), so importing it to reason about events pulls in no ioredis.
The envelope
interface NotifyEvent {
type: NotifyEventType; // 'group.membership_approved' | 'job.expired' | …
id: string; // for logs/tracing — NOT the dedupe key
occurredAt: string; // ISO-8601
recipientUserId: string; // the user to MAIL
idempotencyKey: string; // derived from the immutable domain fact
data: Record<string, unknown>; // template variables
}Two fields are easy to misuse:
recipientUserId is not always the actor. group.join_requested mails the
group owner while the actor is the requester. Only the producing service knows
that relationship, so the producer resolves it.
idempotencyKey must come from the domain fact, never Date.now() or id.
Delivery is at-least-once, so a redelivered event must produce a byte-identical
key; email-service's unique index on it is what actually prevents the
double-send. A key containing a timestamp defeats the mechanism silently — you
just get duplicate mail, with nothing in the logs.
Routing
NOTIFY_ROUTES maps each event type to its template and consent gate:
'group.membership_approved': {
template: 'group-membership-approved',
category: 'notifications',
channel: 'groupActivity',
}It is typed as Record<NotifyEventType, NotifyRoute>, so adding an event type
without routing it is a compile error rather than a silently unsent email.
category must match the template's category in email-service's
registry — that is what drives the compliance
footer. A template registered as marketing but routed here as notifications
would ship without its required footer blocks.
Durability model
The consumer is a Redis consumer group:
- Each event is delivered to the group once; a service reads the backlog it missed while down.
- An entry is
XACK'd only after its handler resolves. A throwing handler leaves it in the Pending Entries List, so it is not lost. This is the retry that email-service's single-send endpoint does not provide. - A periodic
XAUTOCLAIMre-drives entries stuck pastclaimMinIdleMs(e.g. a crash mid-handler), giving at-least-once delivery. - Retry is bounded. After
maxDeliveriesattempts (default 5) the entry is copied tothejob:notify.events.deadand ACKed. Without a bound, one permanently-failing entry is re-driven forever and, sinceXAUTOCLAIMworks oldest-first, delays every entry behind it. PassmaxDeliveries: 0to retry forever.
So handlers must be idempotent, and must distinguish two cases: fail → throw (gets retried); correctly decline → resolve (retrying would never help).
Producers use XADD … MAXLEN ~ 10000, so the stream self-trims.
Consumer groups are cursors
ensureGroup() creates at '0', meaning a new group name replays the entire
stream. That is right for a mirror consumer but wrong for anything that sends
mail. Renaming a live group has the same effect. Verify with
XINFO GROUPS thejob:notify.events.
Adding an event type
- Add it to
NotifyEventTypeandNOTIFY_EVENT_TYPESinsrc/events.ts. - Add its route to
NOTIFY_ROUTESinsrc/routing.ts(compile error until you do). - Confirm the template exists in email-service's registry with a matching category.
- Add a
notifyKeysbuilder and a named method insrc/client/so producers state the fact rather than hand-building the key.
There is no second copy of the contract to keep in sync: src/client/ imports
the envelope and type union from src/events.ts, so the client cannot publish
something the consumer's validator would reject. That used to be step 4, policed
by a wire-compat.test.ts; both are gone.
Why the client is a subpath, not its own package
It was briefly two packages (@thejob/notify + @thejob/notification-client),
on the theory that external producers must not pull in the consumer machinery.
Subpath exports already guarantee that: importing /client reaches client/ and
events.ts only. The split bought nothing and cost a hand-synced copy of the
wire format, so it was merged. Both consumers had ended up installing both
packages anyway.
