Files
2026-05-27 23:29:53 +01:00

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 .code matches the corresponding NOTIFICATION_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: true and the channel's messageId

Scenario: channel send captures transport failure

  • WHEN the underlying transport throws
  • THEN the result has success: false and an error string

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 fetchFn is 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 sendFn receives 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 send is 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 only defaultTemplate is 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 maxCount calls have already been recorded within windowMs
  • THEN the next checkAndRecord throws RateLimitExceededError

Scenario: window expiry releases capacity

  • WHEN the injected Clock advances past windowMs
  • THEN the next checkAndRecord succeeds

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 DeliveryFailedError AND a DeadLetterEntry is 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 have scheduledFor <= 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 Clock is inside the user's quiet-hours window and the priority is below urgentThreshold
  • 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" } AND notification.sent fires on the bus

Scenario: disabled channel rejects the send

  • WHEN the targeted channel has enabled: false
  • THEN send(...) rejects with a SuaveplanError whose code is NOTIFICATION_CHANNEL.CHANNEL_DISABLED

Scenario: rate-limit exhaustion rejects the send

  • WHEN the channel's per-router rateLimit is reached within the 60-second window
  • THEN send(...) rejects with a SuaveplanError whose code is NOTIFICATION_CHANNEL.RATE_LIMIT_EXCEEDED

Scenario: routing rule selects channel by priority

  • WHEN a routing rule with priority: "urgent" and channels: ["sms"] is configured and send({ priority: "urgent" }) is called without channel
  • THEN the result has channel: "sms"

Scenario: cancel after send is rejected

  • WHEN cancel(id) is called against a notification whose status is sent
  • THEN the call rejects with a SuaveplanError whose code is NOTIFICATION_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.routed counter 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/DigestChannel symbols

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)