17 KiB
Capability Spec Delta: notification-channel (add-notification-channel)
Package: @suaveplan/notification-channel
Status: ACTIVE
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Stable error-code registry
The package SHALL expose NOTIFICATION_CHANNEL_CODES built via defineCodes("NOTIFICATION_CHANNEL", [...] as const) with eleven stable reason codes — NOT_FOUND, CHANNEL_DISABLED, TEMPLATE_NOT_FOUND, RATE_LIMIT_EXCEEDED, ROUTING_FAILED, DELIVERY_FAILED, PROVIDER_ERROR, RATE_LIMITED, INVALID_RECIPIENT, CHANNEL_UNAVAILABLE, TIMEOUT — and one typed SuaveplanError subclass per code. src/index.ts MUST side-effect-import ./error-codes.js.
Scenario: every reason code is reachable
- WHEN
Object.keys(NOTIFICATION_CHANNEL_CODES)is enumerated - THEN the eleven literal reasons are present and every value is
"NOTIFICATION_CHANNEL.<REASON>"
Scenario: every subclass carries its code
- WHEN an instance of any concrete error class is created
- THEN its
.codematches the correspondingNOTIFICATION_CHANNEL_CODES.<REASON>literal
Verified by: src/error-codes.test.ts::"NOTIFICATION_CHANNEL_CODES exposes the eleven stable reasons" AND src/error-codes.test.ts::"every error subclass carries its NOTIFICATION_CHANNEL.<REASON> code"
Requirement: Five sending delivery channels conform to a common contract
The package SHALL ship five sending DeliveryChannel implementations — EmailChannel, SmsChannel, PushChannel, InAppChannel, WebhookChannel — each exposing name, canSend(notification), and send(notification) => Promise<DeliveryResult>. Failures from underlying transport MUST be captured into DeliveryResult { success: false, error } rather than thrown.
Scenario: channel send returns success with messageId
- WHEN a sending channel is invoked with a notification it accepts and the transport returns success
- THEN the result has
success: trueand the channel'smessageId
Scenario: channel send captures transport failure
- WHEN the underlying transport throws
- THEN the result has
success: falseand anerrorstring
Verified by: src/channels/email-channel.test.ts::"sends successfully and returns result" AND src/channels/sms-channel.test.ts::"sends successfully and returns result" AND src/channels/push-channel.test.ts::"delivers to a single subscription successfully" AND src/channels/in-app-channel.test.ts::"sends and stores notification in memory" AND src/channels/webhook-channel.test.ts::"sends successfully with 200 response" AND src/channels/email-channel.test.ts::"returns failure when sendFn throws"
Requirement: Webhook and push channels use injectable FetchLike
WebhookChannel and PushChannel SHALL accept an injectable FetchLike from @suaveplan/types so tests run without globalThis.fetch patching and downstream consumers can plug their own retry/observability fetch.
Scenario: webhook routes through injected fetch
- WHEN
WebhookChannel({ fetchFn })is configured and.send(...)is called - THEN the injected
fetchFnis invoked with the recipient URL and JSON payload
Scenario: push routes through injected sendFn
- WHEN
PushChannel({ sendFn })is configured and.send(...)is called - THEN the injected
sendFnreceives the subscription, payload, and VAPID keys
Verified by: src/channels/webhook-channel.test.ts::"sends correct payload in POST body" AND src/channels/push-channel.test.ts::"delivers to a single subscription successfully"
Requirement: DigestChannel batches deliveries through a delegate
DigestChannel SHALL accept any DeliveryChannel as delegate, enqueue notifications per recipient up to maxPerDigest, gate flushes by intervalMs against the injected Clock, and deliver a formatted summary via the delegate when flush is called.
Scenario: digest aggregates and flushes via delegate
- WHEN multiple notifications are enqueued for the same recipient and
flush(recipient)is called - THEN the delegate's
sendis called exactly once with a digest body containing every enqueued message
Verified by: src/digest/digest-channel.test.ts::"delivers a single digest via delegate"
Requirement: TemplateEngine renders templates with channel variants
TemplateEngine SHALL register templates with a defaultTemplate plus optional channelTemplates, render {{var}} placeholders against a flat Record<string,string>, fall back to the default when the requested channel has no variant, and throw NotFoundError for unknown template IDs.
Scenario: channel-specific template wins
- WHEN
render(id, data, "email")is called and an email variant is registered - THEN the email variant body and subject are rendered
Scenario: missing variant falls back to default
- WHEN
render(id, data, "sms")is called and onlydefaultTemplateis registered - THEN the default body is rendered
Verified by: src/templates/template-engine.test.ts::"uses channel-specific template when available" AND src/templates/template-engine.test.ts::"falls back to default when channel variant missing" AND src/templates/template-engine.test.ts::"throws for non-existent template"
Requirement: ChannelRateLimiter enforces sliding-window limits per channel and per user
ChannelRateLimiter SHALL accept per-channel and per-user { maxCount, windowMs } rules, accept an injectable Clock, throw RateLimitExceededError when a check would exceed either limit, support isAllowed non-recording probe, and expose remaining capacity through getChannelRemaining/getUserRemaining.
Scenario: channel limit throws on exhaustion
- WHEN
maxCountcalls have already been recorded withinwindowMs - THEN the next
checkAndRecordthrowsRateLimitExceededError
Scenario: window expiry releases capacity
- WHEN the injected
Clockadvances pastwindowMs - THEN the next
checkAndRecordsucceeds
Verified by: src/rate-limiter/channel-rate-limiter.test.ts::"throws when channel limit exceeded" AND src/rate-limiter/channel-rate-limiter.test.ts::"allows sends after window expires" AND src/rate-limiter/channel-rate-limiter.test.ts::"throws when user limit exceeded"
Requirement: DeliveryRetry wraps a channel with exponential backoff + dead-letter
DeliveryRetry SHALL accept a partial { maxRetries, baseDelayMs, maxDelayMs } config, retry failed send calls up to maxRetries+1 total attempts with exponential backoff capped at maxDelayMs, push exhausted attempts onto an internal dead-letter queue, invoke an optional DeadLetterHandler, and throw DeliveryFailedError on exhaustion.
Scenario: returns first success without retry
- WHEN the wrapped channel succeeds on the first attempt
- THEN the result is returned and no delay is incurred
Scenario: exhausts retries then dead-letters
- WHEN every attempt fails
- THEN the call throws
DeliveryFailedErrorAND aDeadLetterEntryis appended AND the handler is invoked
Verified by: src/retry/delivery-retry.test.ts::"returns result on first success" AND src/retry/delivery-retry.test.ts::"throws after exhausting retries" AND src/retry/delivery-retry.test.ts::"adds to dead letter queue on exhaustion" AND src/retry/delivery-retry.test.ts::"calls dead letter handler"
Requirement: NotificationScheduler manages future deliveries
NotificationScheduler SHALL accept an injectable Clock, expose schedule(input, deliverAt), cancel(id), get(id), listPending(), getDue(now?), async processDue() (calls the registered onDue handler and removes processed entries), and start()/stop() interval driver. start() MUST be idempotent and stop() MUST tolerate calls when no interval is running.
Scenario: due notifications are processed and removed
- WHEN
processDue()is invoked and entries havescheduledFor <= now - THEN the handler is awaited for each non-cancelled entry AND the entry is removed AND the return is the count processed
Verified by: src/scheduler/notification-scheduler.test.ts::"processes due notifications and removes them" AND src/scheduler/notification-scheduler.test.ts::"does not process future notifications" AND src/scheduler/notification-scheduler.test.ts::"start is idempotent" AND src/scheduler/notification-scheduler.test.ts::"stop is idempotent"
Requirement: PreferenceManager enforces per-user opt-in/out + quiet hours
PreferenceManager SHALL accept a PreferenceStoreAdapter plus optional defaultChannels, return default-channel preferences when no record exists, persist enable/disable toggles, persist quiet-hours config, accept an injectable Clock for the quiet-hours probe, and answer canDeliver(userId, channel, priority) by combining channel opt-in/out, default-channel allowance, and quiet-hours vs urgentThreshold.
Scenario: default channels are allowed without explicit enable
- WHEN a user has no stored preferences and the channel is in
defaultChannels - THEN
canDeliver(...)returns true
Scenario: quiet hours block sub-threshold priorities
- WHEN the injected
Clockis inside the user's quiet-hours window and the priority is belowurgentThreshold - THEN
canDeliver(...)returns false
Verified by: src/preferences/preference-manager.test.ts::"returns defaults when no preferences stored" AND src/preferences/preference-manager.test.ts::"blocks low priority during quiet hours" AND src/preferences/preference-manager.test.ts::"allows urgent during quiet hours" AND src/preferences/preference-manager.test.ts::"respects custom urgentThreshold"
Requirement: NotificationRouter is the primary routing surface
NotificationRouter SHALL accept channels, optional templates, optional routing rules, a default channel, an injectable Clock, and optional pre-registered DeliveryChannel implementations. send(input) MUST resolve the channel (input override → routing rules → default), apply the notification:routing hook pipeline, resolve content (template or raw body), check enabled+rate-limit, persist the notification record, emit notification.sent or notification.failed over the internal EventBus, and optionally dispatch to a registered DeliveryChannel with the routed notification.
Scenario: notification with input channel is sent and emitted
- WHEN
send({ channel: "email", recipient, body })succeeds - THEN the result is
{ status: "sent", channel: "email" }ANDnotification.sentfires on the bus
Scenario: disabled channel rejects the send
- WHEN the targeted channel has
enabled: false - THEN
send(...)rejects with aSuaveplanErrorwhose code isNOTIFICATION_CHANNEL.CHANNEL_DISABLED
Scenario: rate-limit exhaustion rejects the send
- WHEN the channel's per-router
rateLimitis reached within the 60-second window - THEN
send(...)rejects with aSuaveplanErrorwhose code isNOTIFICATION_CHANNEL.RATE_LIMIT_EXCEEDED
Scenario: routing rule selects channel by priority
- WHEN a routing rule with
priority: "urgent"andchannels: ["sms"]is configured andsend({ priority: "urgent" })is called withoutchannel - THEN the result has
channel: "sms"
Scenario: cancel after send is rejected
- WHEN
cancel(id)is called against a notification whose status issent - THEN the call rejects with a
SuaveplanErrorwhose code isNOTIFICATION_CHANNEL.ROUTING_FAILED
Verified by: src/router/notification-router.test.ts::"sends a notification to a channel" AND src/router/notification-router.test.ts::"emits notification.sent event" AND src/router/notification-router.test.ts::"throws when channel is disabled" AND src/router/notification-router-routing.test.ts::"enforces rate limit per channel" AND src/router/notification-router-routing.test.ts::"routes based on priority rule" AND src/router/notification-router.test.ts::"throws when cancelling sent notification"
Requirement: Hooks pipeline runs before final routing
The package SHALL expose notificationHooks.hook("notification:routing", handler) and the convenience onNotificationRouting(handler). Registered handlers MUST receive { input, resolvedChannel, metadata }, MAY return a partial override merged into the data flowing forward, and the router MUST adopt the final resolvedChannel for downstream resolution.
Scenario: hook overrides the resolved channel
- WHEN a handler returns
{ resolvedChannel: "sms" }for an input whose default would resolve to"email" - THEN the notification is routed to SMS
Verified by: src/hooks/hooks.test.ts::"hook handler overrides resolved channel" AND src/hooks/hooks.test.ts::"multiple handlers compose left-to-right"
Requirement: OTel metrics on the router hot path
The package SHALL register four instruments — notification_channel.routed (Counter), notification_channel.delivery_attempts (Counter), notification_channel.dead_letters (Counter), notification_channel.latency_ms (Histogram) — on a meter named @suaveplan/notification-channel, refreshable via refreshNotificationMeters() so tests can rebind them against an in-memory MeterProvider. Every NotificationRouter.send invocation MUST record routed and latency_ms with the resolved channel and the outcome.
Scenario: router records a routed counter on every send
- WHEN
send(...)succeeds - THEN the
notification_channel.routedcounter increments by one with{ channel, outcome: "ok" }
Verified by: src/otel/metrics.test.ts::"router records routed counter on send" AND src/otel/metrics.test.ts::"router records latency histogram on send"
Requirement: Zod boundary schemas validate every public option bundle
The package SHALL expose Zod schemas for every public option bundle — NotificationRouterConfigSchema, EmailChannelConfigSchema, SmsChannelConfigSchema, WebhookChannelConfigSchema, PushChannelConfigSchema, DigestChannelConfigSchema, RetryConfigSchema, RateLimitRuleSchema, ChannelConfigSchema, RoutingRuleSchema, NotificationInputSchema, NotificationTemplateSchema, TemplateRegistrationSchema, PreferenceManagerOptionsSchema — and call .parse(...) at the constructor / public-method boundary so malformed inputs are rejected with a structured Zod error before any side effect runs.
Scenario: invalid router config is rejected at construction
- WHEN
new NotificationRouter({ channels: "wrong" } as any)is called - THEN a Zod validation error is thrown before any internal state is set up
Verified by: src/schemas/schemas.test.ts::"router config schema rejects malformed channels" AND src/schemas/schemas.test.ts::"every public option schema parses a real example"
Requirement: FakeFetch test double for deterministic HTTP
The package SHALL ship a FakeFetch class implementing FetchLike, queueing scripted FakeFetchResponse entries in FIFO order, recording every call into a calls array, and supporting pending (never-resolves with abort) and reject (synchronous rejection) modes for abort/timeout tests. The double MUST be importable via the ./fakes/fake-fetch subpath.
Scenario: scripted response is consumed in order
- WHEN two responses are pushed and the fake-fetch is invoked twice
- THEN each call receives the corresponding scripted response and the calls array contains both invocations
Verified by: src/fakes/fake-fetch.test.ts::"consumes scripted responses in FIFO order" AND src/fakes/fake-fetch.test.ts::"rejects when queue is empty"
Requirement: Universal-tier purity
The package SHALL ship as universal-tier — zero node:* imports, zero React, zero DOM-only globals at module load — so it loads identically in Bun, Node, and the browser. "sideEffects": false MUST hold and the only top-level side effect MUST be the registration of the notificationHooks singleton plus the meter-creation calls in otel/metrics.ts.
Scenario: bundler treeshakes unused channels
- WHEN a consumer imports only
EmailChannel - THEN the produced bundle does not contain
WebhookChannel/PushChannel/SmsChannel/InAppChannel/DigestChannelsymbols
Verified by: src/index.ts (every export is a pure value/class — universal-tier purity is enforced statically by the tsconfig.lib.json lib: ["ES2022", "DOM"] and the lack of any node:* import — checked at lint time via the dependency invariants the package's package.json declares)