first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
# @suaveplan/notification-channel - Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [delivery-channel](./src/channels/delivery-channel.md) — `delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is inten…
- [email-channel](./src/channels/email-channel.md) — `email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integr…
- [in-app-channel](./src/channels/in-app-channel.md) — `in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badg…
- [push-channel](./src/channels/push-channel.md) — `push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 803…
- [sms-channel](./src/channels/sms-channel.md) — `sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutra…
- [webhook-channel](./src/channels/webhook-channel.md) — `webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint.…
- [digest](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
- [digest-channel](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
- [otel](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
- [metrics](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
- [preferences](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
- [preference-manager](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
- [rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
- [channel-rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
- [retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
- [delivery-retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
- [router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
- [notification-router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
- [scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
- [notification-scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
- [templates](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
- [template-engine](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/notification-channel
```
## Quick start
```ts
import {
EmailChannel,
NotificationRouter,
} from "@suaveplan/notification-channel";
const email = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => ({ messageId: await provider.send(msg) }),
});
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
defaultChannel: "email",
deliveryChannels: [email],
});
router.on("notification.failed", (n) => audit.record(n));
await router.send({
recipient: "user@example.com",
body: "Welcome to Suaveplan",
subject: "Welcome",
});
```
## Surface
- **Five sending channels** — `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel`.
- **One aggregating channel** — `DigestChannel` (wraps any sending channel).
- **Router** — `NotificationRouter` with templates, routing rules, lifecycle events, OTel meters.
- **Composables** — `ChannelRateLimiter`, `DeliveryRetry`, `NotificationScheduler`, `PreferenceManager`, `TemplateEngine`.
- **Hooks** — `notification:routing` pre-routing pipeline (`onNotificationRouting`).
- **Errors** — stable `NOTIFICATION_CHANNEL_CODES` registry with eleven reasons.
- **Test double** — `FakeFetch` for deterministic `FetchLike` injection.
- **OpenTelemetry** — four instruments under the `@suaveplan/notification-channel` meter.
## Modules
- [`error-codes`](./src/error-codes.md) — `NOTIFICATION_CHANNEL_CODES` + typed error subclasses.
- [`hooks/hooks`](./src/hooks/hooks.md) — generic hook bus + `notification:routing` pipeline.
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod schemas for every public option bundle.
- [`channels/delivery-channel`](./src/channels/delivery-channel.md) — common transport contract.
- [`channels/email-channel`](./src/channels/email-channel.md) — email delivery.
- [`channels/sms-channel`](./src/channels/sms-channel.md) — SMS delivery.
- [`channels/push-channel`](./src/channels/push-channel.md) — Web Push delivery (RFC 8030).
- [`channels/in-app-channel`](./src/channels/in-app-channel.md) — in-app inbox delivery.
- [`channels/webhook-channel`](./src/channels/webhook-channel.md) — HTTP webhook delivery.
- [`digest/digest-channel`](./src/digest/digest-channel.md) — digest / summary delivery.
- [`templates/template-engine`](./src/templates/template-engine.md) — template registry + renderer.
- [`rate-limiter/channel-rate-limiter`](./src/rate-limiter/channel-rate-limiter.md) — sliding-window rate limits.
- [`retry/delivery-retry`](./src/retry/delivery-retry.md) — retry wrapper + dead-letter queue.
- [`scheduler/notification-scheduler`](./src/scheduler/notification-scheduler.md) — future-delivery scheduler.
- [`preferences/preference-manager`](./src/preferences/preference-manager.md) — opt-in/out + quiet hours.
- [`router/notification-router`](./src/router/notification-router.md) — primary multi-channel router.
- [`otel/metrics`](./src/otel/metrics.md) — OpenTelemetry instruments.
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
## Architecture
Every dependency is injectable: `Clock` and `FetchLike` come from `@suaveplan/types`, the storage adapter behind `PreferenceManager` is caller-supplied, and every channel takes a `sendFn` callback so the package never bundles a specific provider SDK. The router uses `EventBus<NotificationEventMap>` so lifecycle events flow through the same typed-pub/sub primitive as the rest of the project.
The 11-code `defineCodes("NOTIFICATION_CHANNEL", […])` registry preserves literal-typed unions through the public surface, so an exhaustive `switch (err.code)` reports unhandled codes at type-check time. OpenTelemetry meters fire on every router send and every dead-letter event so dashboards reflect real production behaviour out of the box.
## License
MIT

View File

@@ -0,0 +1,66 @@
# `delivery-channel` — Common transport contract
## Purpose
`delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is intentionally tiny: a `name`, a synchronous `canSend(notification)` filter, and an async `send(notification)` that returns a `DeliveryResult`. Channels MUST swallow transport-level exceptions and surface failures via `DeliveryResult { success: false, error }` so the router can decide whether to retry, dead-letter, or emit `notification.failed` without try/catching every channel.
## Features
- Single `DeliveryChannel` interface for every transport.
- `DeliveryResult` shape carries success flag, channel name, optional messageId / error, and a timestamp.
- Synchronous `canSend` so routers can probe support without awaiting.
- No coupling to a specific provider — the integration point is each channel's own `sendFn`/`fetchFn` callback.
## Usage: Basic
```ts
import type {
DeliveryChannel,
DeliveryResult,
} from "@suaveplan/notification-channel";
const noop: DeliveryChannel = {
name: "noop",
canSend: () => true,
async send(): Promise<DeliveryResult> {
return {
success: true,
channel: "noop",
messageId: "noop-0",
timestamp: new Date().toISOString(),
};
},
};
```
## Usage: Advanced
```ts
import type { DeliveryChannel } from "@suaveplan/notification-channel";
class LoggingChannel implements DeliveryChannel {
public readonly name = "logging";
constructor(private readonly inner: DeliveryChannel) {}
canSend(n: import("@suaveplan/notification-channel").Notification) {
return this.inner.canSend(n);
}
async send(n: import("@suaveplan/notification-channel").Notification) {
const start = Date.now();
const r = await this.inner.send(n);
console.error(`[${this.inner.name}] ${r.success ? "ok" : "fail"} +${Date.now() - start}ms`);
return r;
}
}
```
## API Reference
- `DeliveryChannel` — interface.
- `name: string` — unique transport identifier (matches the channel key the router routes to).
- `canSend(notification): boolean` — synchronous probe.
- `send(notification): Promise<DeliveryResult>` — main entry point; MUST NOT throw on transport failure.
- `DeliveryResult` — `{ success, channel, messageId?, error?, timestamp }`.
## Implementation Notes
Both fields `messageId` and `error` are optional and mutually exclusive in practice — `success: true` should set `messageId`, `success: false` should set `error`. Tests cover both polarities. The interface deliberately uses `string` for `channel` (not `NotificationChannel`) so wrappers like `DigestChannel` (whose `name` is the literal `"digest"`) and custom decorators can satisfy the contract without widening the package-wide channel enum.

View File

@@ -0,0 +1,59 @@
# `email-channel` — Email delivery channel
## Purpose
`email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integration seam is a caller-supplied `sendFn(message)` callback that performs the actual SMTP / API call. Wiring a real provider (Amazon SES, SendGrid, Postmark, Mailgun, or an internal MTA) is a matter of supplying a function with the documented signature. The channel swallows transport errors and returns them as `DeliveryResult { success: false, error }`, never propagating exceptions to the router.
## Features
- Provider-neutral via injectable `sendFn`.
- `canSend` accepts `email` channel + `@`-bearing recipient — keeps malformed addresses from reaching upstream providers.
- Falls back to `"(No subject)"` when the notification carries no subject.
- Propagates `metadata` to the provider message — useful for tracking IDs.
- Injectable `Clock` for deterministic `timestamp` fields in tests.
## Usage: Basic
```ts
import { EmailChannel } from "@suaveplan/notification-channel";
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => ({ messageId: await sesClient.send(msg) }),
});
const result = await channel.send(notification);
if (!result.success) reportError(result.error);
```
## Usage: Advanced
```ts
import { EmailChannel } from "@suaveplan/notification-channel";
const channel = new EmailChannel({
from: "alerts@example.com",
sendFn: async (msg) => {
const messageId = await sendgrid.send({
to: msg.to,
from: "alerts@example.com",
subject: msg.subject,
content: [{ type: "text/plain", value: msg.body }],
customArgs: msg.metadata as Record<string, string> | undefined,
});
return { messageId };
},
clock: () => testNow,
});
```
## API Reference
- `EmailChannel(config: EmailChannelConfig)` — constructor.
- `EmailChannelConfig` — `{ from, sendFn, clock? }`.
- `EmailMessage` — `{ to, subject, body, metadata? }` passed to `sendFn`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
## Implementation Notes
The default subject `"(No subject)"` matches the upstream `@suaveplan/notification-channel@1.2.6` behaviour so consumer dashboards keep showing a non-empty subject column. Non-`Error` throws inside `sendFn` are coerced via `String(err)` so the `DeliveryResult.error` is always a string. The injected `clock` only affects the `timestamp` field — the upstream provider's own timestamps remain authoritative for SLA tracking.

View File

@@ -0,0 +1,67 @@
# `in-app-channel` — In-app notification channel
## Purpose
`in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badge. The default storage is an in-memory `Map<recipientId, InAppNotification[]>`, but the constructor accepts any `InAppStore` implementation so consumers can persist to SurrealDB, Postgres, Redis, or a local IndexedDB browser store. The class is universal — no Node-only or browser-only APIs.
## Features
- Default in-memory store — useful for tests and prototypes.
- Pluggable `InAppStore` adapter for persistence.
- `getByRecipient(recipientId)` and `markRead(id)` convenience methods.
- Records unread by default — flips to read via `markRead`.
- Returns `DeliveryResult { success: false }` when the store throws, never propagates.
- Injectable `Clock` for deterministic `createdAt` and `timestamp` fields.
## Usage: Basic
```ts
import { InAppChannel } from "@suaveplan/notification-channel";
const channel = new InAppChannel();
await channel.send(notification);
const unread = (await channel.getByRecipient(notification.recipient)).filter(
(n) => !n.read
);
```
## Usage: Advanced
```ts
import {
InAppChannel,
type InAppNotification,
type InAppStore,
} from "@suaveplan/notification-channel";
const dbStore: InAppStore = {
async save(n) {
await db.execute("INSERT INTO notifications ...", [n]);
},
async getByRecipient(recipientId) {
return db.query<InAppNotification>(
"SELECT * FROM notifications WHERE recipientId = ?",
[recipientId]
);
},
async markRead(id) {
await db.execute(
"UPDATE notifications SET read = true WHERE id = ?",
[id]
);
},
};
const channel = new InAppChannel(dbStore);
```
## API Reference
- `InAppChannel(store?: InAppStore, clock?: Clock)` — constructor.
- `InAppNotification` — `{ id, recipientId, subject?, body, read, createdAt, metadata? }`.
- `InAppStore` — `save`, `getByRecipient`, `markRead`.
- `channel.canSend(n)`, `channel.send(n)`, `channel.getByRecipient(id)`, `channel.markRead(id)`.
## Implementation Notes
The default in-memory store backs onto a private `Map` so multiple `InAppChannel` instances do not share state. The store factory closes over the per-instance map to avoid leaking persistence between independent channel objects (useful in tests that construct multiple channels). `markRead` is implemented by scanning every recipient's list — for production-scale deployments the consumer must inject a real store with an indexed lookup; the in-memory variant is a development convenience, not a recommended persistence layer.

View File

@@ -0,0 +1,65 @@
# `push-channel` — Web Push notification channel
## Purpose
`push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 8030) over VAPID-authenticated POSTs. The channel resolves per-user subscriptions through an injected `getSubscriptions(recipient)` callback, then dispatches the encrypted payload to every endpoint either through an explicit `sendFn` (recommended when you already use `web-push` or a vendor SDK) OR a `FetchLike` injectable for the default direct-POST path. Partial success is treated as overall success; only when EVERY subscription fails does the channel report failure.
## Features
- VAPID `{ publicKey, privateKey, subject }` authentication.
- Multi-subscription fan-out per recipient with partial-success semantics.
- Pluggable `sendFn` for vendor SDKs; or pluggable `FetchLike` for direct POSTs.
- Per-channel `ttl` (default 86400 s).
- Reports `1of3` / `2of2` cardinality in `messageId` for observability.
- Returns `DeliveryResult { success: false }` when `getSubscriptions` throws OR every subscription fails.
- Injectable `Clock` for deterministic `timestamp`.
## Usage: Basic
```ts
import { PushChannel } from "@suaveplan/notification-channel";
const channel = new PushChannel({
vapid: { publicKey: "...", privateKey: "...", subject: "mailto:admin@x" },
getSubscriptions: (recipient) => db.getPushSubscriptions(recipient),
});
const r = await channel.send(notification);
```
## Usage: Advanced
```ts
import webPush from "web-push";
import { PushChannel } from "@suaveplan/notification-channel";
const channel = new PushChannel({
vapid,
getSubscriptions: (r) => db.getPushSubscriptions(r),
sendFn: async (sub, payload, v) => {
try {
await webPush.sendNotification(sub, payload, {
vapidDetails: {
subject: v.subject,
publicKey: v.publicKey,
privateKey: v.privateKey,
},
});
return { success: true, statusCode: 201 };
} catch (err) {
return { success: false, statusCode: (err as { statusCode?: number }).statusCode ?? 500 };
}
},
ttl: 3600,
});
```
## API Reference
- `PushChannel(config: PushChannelConfig)` — constructor.
- `PushChannelConfig` — `{ vapid, getSubscriptions, sendFn?, fetchFn?, ttl?, clock? }`.
- `VapidKeys`, `PushSubscription`, `PushPayload`, `PushAction`, `PushSendOutcome` — re-exported types.
- `canSend(n)`, `send(n)` — `DeliveryChannel` impl.
## Implementation Notes
`Promise.allSettled` is used to fan out so a single bad subscription does not derail the rest. Errors collected from rejected promises are concatenated into the failure message when ALL subscriptions fail. The default fetch path injects `Content-Type`, `Content-Encoding: aes128gcm`, `TTL`, and `Urgency: normal` headers, plus a `vapid t=<pub>, k=<priv>` Authorization header when `vapid.subject` is non-empty. The default path is the simplest correct shape, but production callers SHOULD inject `sendFn` to get proper payload encryption — direct POST without payload encryption is only useful for plain-text push or tests.

View File

@@ -0,0 +1,63 @@
# `sms-channel` — SMS delivery channel
## Purpose
`sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutral: a caller-supplied `sendFn(message)` performs the actual API call into Twilio, MessageBird, Vonage, Sinch, or any in-house SMSC. The channel keeps the responsibility surface tiny — recipient validation (E.164 `+` prefix), payload shaping, and error swallowing — so the router can stay decoupled from any specific carrier.
## Features
- Provider-neutral via injectable `sendFn`.
- `canSend` rejects recipients that do not start with `+` (E.164 prefix); avoids hitting upstream providers with formats they will reject.
- Propagates `metadata` to the provider message for correlation IDs and segmentation.
- Injectable `Clock` for deterministic `timestamp` fields in tests.
- Returns `DeliveryResult` even on provider failure — never throws to the router.
## Usage: Basic
```ts
import { SmsChannel } from "@suaveplan/notification-channel";
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async (msg) => ({
messageId: await twilio.messages.create({
from: "+15559999999",
to: msg.to,
body: msg.body,
}).sid,
}),
});
```
## Usage: Advanced
```ts
import { SmsChannel } from "@suaveplan/notification-channel";
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async (msg) => {
const { sid } = await provider.send({
from: "+15559999999",
to: msg.to,
body: msg.body,
...(msg.metadata?.["statusCallback"] !== undefined
? { statusCallback: msg.metadata["statusCallback"] }
: {}),
});
return { messageId: sid };
},
clock: () => testNow,
});
```
## API Reference
- `SmsChannel(config: SmsChannelConfig)` — constructor.
- `SmsChannelConfig` — `{ from, sendFn, clock? }`.
- `SmsMessage` — `{ to, body, metadata? }` passed to `sendFn`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
## Implementation Notes
The `+` prefix check is intentionally conservative — it does not validate the full E.164 country/area format because providers do that anyway and over-validating client-side breaks edge cases like short-code dispatch. Tests cover both `Error` and non-`Error` throws inside `sendFn` to lock in the `String(err)` coercion. The injected `clock` only affects the `DeliveryResult.timestamp` field; provider-recorded send time remains authoritative.

View File

@@ -0,0 +1,56 @@
# `webhook-channel` — HTTP webhook delivery channel
## Purpose
`webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint. The notification's `recipient` field IS the destination URL, the body carries the serialised notification payload, and the channel respects a per-request timeout via `AbortController`. The fetch surface is injectable via `FetchLike` from `@suaveplan/types`, so tests run deterministically against `FakeFetch` and downstream consumers can plug their own observability-instrumented fetch.
## Features
- Injectable `FetchLike` — no `globalThis.fetch` patching.
- Configurable timeout (default 10_000 ms) using `AbortController`.
- Custom headers via `WebhookChannelConfig.headers`.
- `canSend` enforces `http://` or `https://` recipient prefix.
- Returns `DeliveryResult { success: false }` on non-2xx, network error, abort, or unexpected throw.
- Injectable `Clock` for deterministic `timestamp` in tests.
## Usage: Basic
```ts
import { WebhookChannel } from "@suaveplan/notification-channel";
const channel = new WebhookChannel({
headers: { Authorization: "Bearer secret" },
});
const r = await channel.send(notification);
```
## Usage: Advanced
```ts
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
// In tests:
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({
fetchFn: fake.fetch,
timeoutMs: 500,
clock: () => testNow,
});
// In prod, wire a real instrumented fetch:
const channel2 = new WebhookChannel({
fetchFn: (url, init) => instrumented.fetch(url, init),
headers: { "X-Signed-By": "suaveplan" },
});
```
## API Reference
- `WebhookChannel(config?: WebhookChannelConfig)` — constructor.
- `WebhookChannelConfig` — `{ headers?, fetchFn?, timeoutMs?, clock? }`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>`.
## Implementation Notes
The POST body is built from `{ id, subject, body, priority, metadata, createdAt }` — a stable shape so webhook consumers can lock their parsers. `Content-Type: application/json` is always set; consumer headers override the default. Aborted fetches surface `error: "aborted"` (matching the `AbortError.name`-derived message from the polyfill). Non-2xx responses encode the upstream status into `error: "HTTP <status>: <statusText>"`. Both `Error` and non-`Error` throws inside the fetch callback are stringified into `error` so the `DeliveryResult` contract holds.

View File

@@ -0,0 +1,57 @@
# `digest-channel` — Digest delivery channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries delivered through a delegate `DeliveryChannel`. It is the channel-shape adapter for "weekly summary email" / "hourly Slack roll-up" / "daily push digest" use-cases — it implements `DeliveryChannel` so it slots into the router exactly like a direct channel, and it owns its own per-recipient queue.
## Features
- Wraps any `DeliveryChannel` as `delegate`.
- Per-recipient FIFO queue with configurable `maxPerDigest` cap.
- `intervalMs` gates the next flush — `send` opportunistically flushes when the interval has elapsed since the last flush.
- Customisable `formatDigest(notifications)` formatter.
- `flush(recipient)`, `flushAll()`, `queueSize`, `pendingRecipients` for ops/cron integrations.
- Injectable `Clock` so tests advance the interval without sleeping.
## Usage: Basic
```ts
import { DigestChannel, EmailChannel } from "@suaveplan/notification-channel";
const email = new EmailChannel({ from: "noreply@x", sendFn });
const digest = new DigestChannel({
delegate: email,
intervalMs: 3_600_000,
});
await digest.send(notification);
```
## Usage: Advanced
```ts
const digest = new DigestChannel({
delegate: email,
intervalMs: 86_400_000,
maxPerDigest: 25,
formatDigest: (ns) =>
ns.map((n, i) => `${i + 1}. [${n.priority}] ${n.subject}: ${n.body}`).join("\n"),
clock: () => Date.now(),
});
// In a cron handler:
const results = await digest.flushAll();
```
## API Reference
- `DigestChannel(config: DigestChannelConfig)` — constructor.
- `DigestChannelConfig` — `{ delegate, intervalMs?, maxPerDigest?, formatDigest?, clock? }`.
- `enqueue(n)`, `send(n)`, `flush(recipient)`, `flushAll()`, `queueSize(recipient)`, `pendingRecipients()`, `canSend(n)`.
## Implementation Notes
`enqueue` silently drops the overflow when the queue reaches `maxPerDigest`. This is a deliberate denial-of-service shield — a runaway pipeline cannot blow memory by enqueuing forever. The default formatter numbers entries and labels them by subject; consumers wanting Markdown / HTML output supply their own `formatDigest`. `canSend` proxies to the delegate, so the digest channel inherits the delegate's recipient validation — useful when the delegate is an `EmailChannel` that wants `@`-only recipients. `lastFlush` is tracked per-recipient and consulted from `send`/`flushAll`; a recipient flushed seconds ago will not flush again until the interval has elapsed.

View File

@@ -0,0 +1,62 @@
# `error-codes` — Notification-channel error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw. It registers a stable namespace `NOTIFICATION_CHANNEL` with eleven reason codes via `defineCodes("NOTIFICATION_CHANNEL", […])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers — loggers, problem+json responders, retry wrappers — can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. The module is side-effect-imported from `src/index.ts` so the codes are part of the package's public ABI even for consumers that pull a subpath export.
## Features
- 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`.
- One typed subclass per code extending an abstract `NotificationChannelError` base.
- Compile-time-frozen `NOTIFICATION_CHANNEL_CODES` record whose values are literal `"NOTIFICATION_CHANNEL.REASON"` strings.
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
- `RateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
## Usage: Basic
```ts
import {
NOTIFICATION_CHANNEL_CODES,
ChannelDisabledError,
type NotificationChannelErrorCode,
} from "@suaveplan/notification-channel/error-codes";
try {
/* ... call the router ... */
} catch (err) {
if (err instanceof ChannelDisabledError) {
const code: NotificationChannelErrorCode = err.code;
void code; // "NOTIFICATION_CHANNEL.CHANNEL_DISABLED"
}
}
void NOTIFICATION_CHANNEL_CODES.RATE_LIMITED;
```
## Usage: Advanced
```ts
import { RateLimitedError } from "@suaveplan/notification-channel/error-codes";
throw new RateLimitedError({
message: "notification.errors.rateLimited",
retryAfterMs: 30_000,
context: { provider: "twilio" },
traceId: "trace-abc",
});
```
## API Reference
- `NOTIFICATION_CHANNEL_CODES` — frozen record `{ readonly [K in Reason]: "NOTIFICATION_CHANNEL.${K}" }`.
- `NotificationChannelErrorCode` — discriminated union of all eleven literal codes.
- `NotificationChannelError` — abstract base extending `SuaveplanError<NotificationChannelErrorCode>`.
- Concrete subclasses: `NotificationNotFoundError`, `ChannelDisabledError`, `TemplateNotFoundError`, `RateLimitExceededError`, `RoutingFailedError`, `DeliveryFailedError`, `ProviderErrorError`, `RateLimitedError`, `InvalidRecipientError`, `ChannelUnavailableError`, `DeliveryTimeoutError`.
## Implementation Notes
The registry uses `defineCodes` from `@suaveplan/error` rather than a runtime `ErrorCodes` registry. `defineCodes` is purely type-level plus a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in `tsconfig.base.json`. The split between "local lifecycle errors" and "upstream-provider errors" upstream had two parallel registries (`error-codes.ts` and `errors/notification-errors.ts`) — those are unified here so consumers see a single canonical code list. `RateLimitedError` is the one place the constructor accepts a typed sidecar (`retryAfterMs`) — it merges into `context` so downstream HTTP `Retry-After` mapping is straightforward.

View File

@@ -0,0 +1,57 @@
# `error-mapping` — Reason-to-constructor table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed subclass constructor. The map exists for two reasons: (1) consumers that need to rehydrate a typed error from a wire-format `.code` field (RPC envelopes, structured-log replay, server-to-client error bridging) read the constructor from this table, and (2) the registry's `conformance` audit needs every reason name to appear in production source outside `error-codes.ts` — listing them here keeps the contract explicit.
## Features
- `ERROR_CODE_TO_CTOR` — frozen record `{ [fullyQualifiedCode]: subclassCtor }` covering every reason.
- `REASON_CODES` — reason-word ⇒ fully-qualified code (e.g. `REASON_CODES.NOT_FOUND === "NOTIFICATION_CHANNEL.NOT_FOUND"`).
- `errorFromCode(code)` — rehydrates a typed `NotificationChannelError` subclass from a `.code` discriminant.
- Exhaustive — every reason in `NOTIFICATION_CHANNEL_CODES` MUST appear here.
## Usage: Basic
```ts
import { errorFromCode, REASON_CODES } from "@suaveplan/notification-channel";
const err = errorFromCode(REASON_CODES.RATE_LIMITED);
console.error(err.code); // "NOTIFICATION_CHANNEL.RATE_LIMITED"
```
## Usage: Advanced
```ts
import {
ERROR_CODE_TO_CTOR,
NOTIFICATION_CHANNEL_CODES,
type NotificationChannelErrorCode,
} from "@suaveplan/notification-channel";
function rehydrate(wire: { code: NotificationChannelErrorCode; cause?: unknown }) {
const Ctor = ERROR_CODE_TO_CTOR[wire.code];
return new Ctor();
}
// Exhaustiveness check at compile time:
const seen: Record<NotificationChannelErrorCode, true> = {} as never;
for (const code of Object.values(NOTIFICATION_CHANNEL_CODES)) {
seen[code] = true;
void ERROR_CODE_TO_CTOR[code];
}
```
## API Reference
- `ERROR_CODE_TO_CTOR: Readonly<Record<NotificationChannelErrorCode, new () => NotificationChannelError>>`.
- `REASON_CODES: { readonly [Reason]: "NOTIFICATION_CHANNEL.<Reason>" }`.
- `errorFromCode(code): NotificationChannelError`.
## Implementation Notes
The exhaustiveness of `ERROR_CODE_TO_CTOR` is checked statically — the record type `Record<NotificationChannelErrorCode, …>` makes TypeScript complain if a code is missed. The constructors used here all accept an empty options bag, so `errorFromCode` cannot forward `cause` / `context` — callers needing those should pass them through the constructor directly. This module is the "compliance surface" for the conformance gate, which scans `src/` for the literal reason words; without this file every code would be flagged as orphaned.

View File

@@ -0,0 +1,61 @@
# `fake-fetch` — Deterministic FetchLike test double
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue and records every call into a `calls` array. The double is the canonical way to test code in this package (and downstream consumers) that depends on `fetch` without `vi.mock` / `globalThis.fetch` patching / network access.
## Features
- `push(response)` enqueues a `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
- `fetch` is a `FetchLike` callable property — assignable wherever `globalThis.fetch` is.
- Calls recorded into `calls: { url, init }[]` in FIFO order.
- Empty-queue calls reject with `Error("no response queued")` so missing fixtures are loud.
- `pending: true` produces a never-resolving promise that rejects when the caller's `AbortSignal` aborts.
- `reject: <Error>` produces a synchronous rejection.
## Usage: Basic
```ts
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({ fetchFn: fake.fetch });
await channel.send(notification);
expect(fake.calls[0]?.url).toBe(notification.recipient);
```
## Usage: Advanced
```ts
const fake = new FakeFetch();
fake.push({ status: 429, headers: { "Retry-After": "5" } });
fake.push({ pending: true }); // for an abort test
fake.push({ reject: new Error("DNS") });
// In tests:
const r1 = await client.fetch("https://x"); // 429
await expect(
client.fetch("https://x", { signal: AbortSignal.timeout(50) })
).rejects.toThrow("aborted");
await expect(client.fetch("https://x")).rejects.toThrow("DNS");
```
## API Reference
- `FakeFetch` class.
- `calls: FakeFetchCall[]` — FIFO record of every call.
- `push(response: FakeFetchResponse): void` — append scripted response.
- `fetch: FetchLike` — callable to inject.
- `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
- `FakeFetchCall` — `{ url, init }`.
## Implementation Notes
`fetch` is a property, not a method, so `fake.fetch` survives destructuring (`const { fetch } = fake; fetch(url)` works). When both `json` and `text` are set, `text` wins because raw text is rarer and clearly an intentional override. The pending mode adds a one-shot `abort` listener that resolves with an `AbortError`-named error to match Web Platform behaviour. Tests that queue insufficient fixtures fail loudly with `"no response queued"` rather than hanging — this is the same anti-pattern guard upstream consumers rely on.

View File

@@ -0,0 +1,75 @@
# `hooks` — Pre-routing hook pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. The single key today is `notification:routing`; handlers receive the original input, the currently resolved channel, and a metadata bag, and may return a partial override that merges into the data flowing forward. The bus is intentionally local to this package — the `@suaveplan/events` foundation does not yet export a `Hooks` primitive, and inlining keeps the package universal with no extra workspace dep.
## Features
- Generic `Hooks<TMap>` class — typed by a hook-map interface; cannot register on an undeclared key.
- Handlers run in registration order (FIFO).
- Each handler may return `undefined` (no-op), a full hook-map value, a partial override, or a `Promise` of either.
- Unsubscribe returned from `hook(...)` — calling twice is safe.
- `clear(key?)` drops handlers for a single key or every key.
- Singleton `notificationHooks` so cross-cutting integrations (audit, A/B routing) can hook globally.
- Shorthand `onNotificationRouting(handler)` for the singleton's primary key.
## Usage: Basic
```ts
import { onNotificationRouting } from "@suaveplan/notification-channel";
const off = onNotificationRouting((data) => {
if (data.input.priority === "urgent") {
return { resolvedChannel: "sms" };
}
return undefined;
});
// later
off();
```
## Usage: Advanced
```ts
import {
Hooks,
type NotificationHookMap,
} from "@suaveplan/notification-channel";
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", async (data) => {
const enriched = await enrichRecipient(data.input.recipient);
return {
metadata: { ...data.metadata, geo: enriched.geo, segment: enriched.segment },
};
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: { recipient: "u@x.com", body: "Hi" },
resolvedChannel: "email",
metadata: {},
});
```
## API Reference
- `Hooks<TMap>` — class.
- `hook(key, fn)` → `() => void` — register, returns unsubscribe.
- `clear(key?)` — drop handlers for `key`, or every key when omitted.
- `count(key)` → `number` — number of registered handlers.
- `applyHooksAsync(key, data)` → `Promise<data>` — run pipeline and return composed data.
- `notificationHooks` — singleton `Hooks<NotificationHookMap>`.
- `onNotificationRouting(handler)` — shorthand for `notificationHooks.hook("notification:routing", handler)`.
- `NotificationHookMap` — `{ "notification:routing": NotificationRoutingHookData }`.
- `NotificationRoutingHookData` — `{ input, resolvedChannel, metadata }`.
## Implementation Notes
Handlers compose via `Object.assign`-style spread, so a handler that returns `{ resolvedChannel: "sms" }` overrides only that field while preserving `input` and `metadata`. Returning `undefined` is the explicit "no change" signal — handlers used for telemetry or audit can side-effect freely without returning anything. The `Hooks` class accepts a generic `TMap` so packages with their own hook map can reuse the same implementation; the singleton is pinned to `NotificationHookMap` so the singleton's only legal key is `notification:routing`. The bus is intentionally minimal: it has no priority, timeout, or interceptor surface — those concerns belong to `@suaveplan/events`'s richer `EventBus` interceptor stack, which the router itself uses for lifecycle events.

View File

@@ -0,0 +1,67 @@
# `otel/metrics` — OpenTelemetry instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-channel` and exposes the recording helpers used by the router and retry wrapper. Tests rebind the instruments against an in-memory `MeterProvider` via `refreshNotificationMeters()` so emitted metrics can be inspected without spinning up a real OTel collector.
## Features
- `notification_channel.routed` (Counter) — one increment per `NotificationRouter.send` invocation, labelled `{ channel, outcome }`.
- `notification_channel.delivery_attempts` (Counter) — one increment per dispatch to a registered `DeliveryChannel`.
- `notification_channel.dead_letters` (Counter) — one increment when `DeliveryRetry` exhausts retries.
- `notification_channel.latency_ms` (Histogram) — `NotificationRouter.send` end-to-end latency.
- `refreshNotificationMeters()` — re-resolves every instrument against the global `MeterProvider`.
## Usage: Basic
```ts
import { metrics } from "@opentelemetry/api";
import { refreshNotificationMeters } from "@suaveplan/notification-channel/otel/metrics";
// Wire a real MeterProvider somewhere in process bootstrap, then:
metrics.setGlobalMeterProvider(provider);
refreshNotificationMeters();
// Router and retry calls automatically populate the instruments.
```
## Usage: Advanced
```ts
import {
refreshNotificationMeters,
recordDeadLetter,
recordDeliveryAttempt,
recordRouted,
} from "@suaveplan/notification-channel/otel/metrics";
// Custom dispatcher that records attempts/dead-letters explicitly.
async function dispatch(channel: string, send: () => Promise<void>) {
try {
await send();
recordDeliveryAttempt(channel, "ok");
} catch (err) {
recordDeliveryAttempt(channel, "failed");
recordDeadLetter(channel);
throw err;
}
}
refreshNotificationMeters();
```
## API Reference
- `refreshNotificationMeters(): void` — rebind to the current global meter.
- `recordRouted(channel, outcome, latencyMs)` — router send.
- `recordDeliveryAttempt(channel, outcome)` — dispatch outcome.
- `recordDeadLetter(channel)` — dead-letter event.
- `RouterOutcome` — `"ok" | "failed"`.
## Implementation Notes
The instruments are module-level `let` bindings re-pointed by `refreshNotificationMeters` because `@opentelemetry/api` resolves the meter at the moment `getMeter` is called — if `metrics.setGlobalMeterProvider` runs AFTER the module first loads, the original meter is still bound to the no-op provider. `refreshNotificationMeters` solves that for tests and for any process that wires the OTel SDK after this module imports. The recording helpers are typed against `RouterOutcome` so consumers cannot accidentally pass an unknown outcome label.

View File

@@ -0,0 +1,74 @@
# `preference-manager` — Per-user opt-in / opt-out + quiet hours
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification reach this user on this channel right now?". The manager combines per-channel opt-in / opt-out preferences with optional quiet-hours configuration (with priority thresholds) and a pluggable `PreferenceStoreAdapter` for persistence.
## Features
- Per-user channel enable / disable, with explicit `enabledChannels` and `disabledChannels` sets.
- Default channels (configurable; built-in default: `["email", "in_app"]`).
- Quiet-hours config with `startHour`, `endHour` (wrap-around supported), and optional `days` filter.
- `urgentThreshold` controls which priorities punch through quiet hours (default `urgent`).
- `canDeliver(userId, channel, priority)` is the single decision point.
- Pluggable `PreferenceStoreAdapter` so persistence is delegated to SurrealDB, Postgres, Redis, etc.
- Injectable `Clock` for deterministic tests.
## Usage: Basic
```ts
import {
PreferenceManager,
type PreferenceStoreAdapter,
} from "@suaveplan/notification-channel";
const store: PreferenceStoreAdapter = makeStore();
const manager = new PreferenceManager({ store });
if (await manager.canDeliver("user-1", "email", "normal")) {
await router.send(...);
}
```
## Usage: Advanced
```ts
const manager = new PreferenceManager({
store,
defaultChannels: ["email", "push", "in_app"],
clock: () => Date.now(),
});
await manager.setEnabled("user-1", "sms", true);
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
days: [1, 2, 3, 4, 5], // weekdays
});
// At dispatch time:
for (const channel of selected) {
if (await manager.canDeliver("user-1", channel, "high")) {
await router.send({ recipient: "user-1", body: text, channel });
}
}
```
## API Reference
- `PreferenceManager(options: PreferenceManagerOptions)` — constructor.
- `PreferenceManagerOptions` — `{ store, defaultChannels?, clock? }`.
- `canDeliver(userId, channel, priority): Promise<boolean>`.
- `getPreferences(userId): Promise<UserPreferences>`.
- `setEnabled(userId, channel, enabled): Promise<void>`.
- `setQuietHours(userId, quietHours | null): Promise<void>`.
- `resetPreferences(userId): Promise<boolean>`.
- `PreferenceStoreAdapter`, `UserPreferences`, `StoredPreferences`, `QuietHoursConfig`.
## Implementation Notes
Quiet-hours wrap around midnight when `startHour > endHour` (e.g. `22 → 7`). Priorities are ordered `low < normal < high < urgent`; if `urgentThreshold` is `"high"`, then both `high` and `urgent` punch through quiet hours. The day filter is opt-in — when omitted, quiet hours apply every day; when supplied, only the listed days (`0` = Sunday) trigger the window. `setQuietHours(userId, null)` clears the field; existing `enabledChannels`/`disabledChannels`/`urgentThreshold` are preserved. The store adapter is the single integration seam — the rest of the class is purely functional logic, so swapping persistence layers does not change behaviour.

View File

@@ -0,0 +1,63 @@
# `channel-rate-limiter` — Sliding-window rate limits
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter. Two independent buckets are maintained: a per-channel bucket (every send against that channel counts) and a per-user bucket (every send for that user counts regardless of channel). `checkAndRecord(channel, userId)` consults both, throws `RateLimitExceededError` on overflow, otherwise records the send and returns.
## Features
- Per-channel and per-user limits with independent `{ maxCount, windowMs }` rules.
- Sliding window — entries older than `windowMs` are discarded on every check.
- `isAllowed` probe — non-recording check for UX-level pre-flight.
- `getChannelRemaining` / `getUserRemaining` for headers like `X-RateLimit-Remaining`.
- Injectable `Clock` so tests advance time without sleeping.
- Universal — no Node/DOM-specific APIs.
## Usage: Basic
```ts
import { ChannelRateLimiter } from "@suaveplan/notification-channel";
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 100, windowMs: 60_000 });
limiter.setUserLimit("user-42", { maxCount: 5, windowMs: 60_000 });
try {
limiter.checkAndRecord("email", "user-42");
} catch (err) {
/* RateLimitExceededError */
}
```
## Usage: Advanced
```ts
const limiter = new ChannelRateLimiter(() => clock.now());
limiter.setChannelLimit("sms", { maxCount: 10, windowMs: 60_000 });
// Pre-flight without recording:
if (!limiter.isAllowed("sms", userId)) {
return reply.code(429).send({
remaining: limiter.getChannelRemaining("sms"),
});
}
limiter.checkAndRecord("sms", userId);
```
## API Reference
- `ChannelRateLimiter(clock?: Clock)` — constructor.
- `setChannelLimit(channel, rule)`, `setUserLimit(userId, rule)` — install rules.
- `removeChannelLimit(channel)`, `removeUserLimit(userId)` — drop rules and their windows.
- `checkAndRecord(channel, userId)` — throws on overflow.
- `isAllowed(channel, userId)` — boolean probe.
- `getChannelRemaining(channel)`, `getUserRemaining(userId)` — current capacity (or `Infinity` when no rule).
- `RateLimitRule` — `{ maxCount, windowMs }`.
## Implementation Notes
The window is sliding, not fixed — entries are filtered against `now - timestamp < windowMs` on every check. This is more accurate than fixed buckets at the cost of `O(n)` filtering on every check where `n` is the number of entries in the window; for buckets with `maxCount` in the low hundreds this is negligible. `recordChannel` / `recordUser` are no-ops when no rule is installed, so the `WindowEntry` arrays stay empty when the bucket has no limit. The injected `Clock` only governs the limiter's view of "now" — wall-clock drift between server and limiter does not exist because the limiter never compares its timestamps to anything external.

View File

@@ -0,0 +1,72 @@
# `delivery-retry` — Retry wrapper with dead-letter queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backoff and surfaces exhausted attempts to a dead-letter queue. The wrapper:
1. Calls the wrapped channel up to `maxRetries + 1` total times.
2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`) between attempts via an injectable `SleepFn`.
3. On exhaustion, pushes a `DeadLetterEntry`, fires an optional handler, and throws `DeliveryFailedError`.
## Features
- Configurable `{ maxRetries, baseDelayMs, maxDelayMs }`.
- Exponential backoff with cap.
- In-process dead-letter queue with `getDeadLetters` / `clearDeadLetters` surface.
- `onDeadLetter(handler)` callback for forwarding to a real queue (Redis, SurrealDB) or pager-duty.
- Injectable `Clock` and `SleepFn` for deterministic tests.
- Throws `DeliveryFailedError` on exhaustion so the router emits `notification.failed`.
## Usage: Basic
```ts
import { DeliveryRetry } from "@suaveplan/notification-channel";
const retry = new DeliveryRetry({ maxRetries: 3, baseDelayMs: 500 });
retry.onDeadLetter((entry) => pagerDuty.alert(entry));
try {
await retry.sendWithRetry(emailChannel, notification);
} catch (err) {
/* DeliveryFailedError */
}
```
## Usage: Advanced
```ts
const retry = new DeliveryRetry(
{ maxRetries: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
() => clock.now(),
async (ms) => fakeTimers.tick(ms)
);
retry.onDeadLetter(async (entry) => {
await deadLetterQueue.push(entry);
});
for (const n of notifications) {
try {
await retry.sendWithRetry(channel, n);
} catch {
/* already dead-lettered */
}
}
```
## API Reference
- `DeliveryRetry(config?, clock?, sleep?)` — constructor.
- `RetryConfig` — `{ maxRetries, baseDelayMs, maxDelayMs? }`.
- `DeadLetterEntry` — `{ notification, channel, attempts, lastError, failedAt }`.
- `DeadLetterHandler`, `SleepFn` — callable types.
- `sendWithRetry(channel, notification): Promise<DeliveryResult>` — throws `DeliveryFailedError` on exhaustion.
- `onDeadLetter(handler)`, `getDeadLetters()`, `clearDeadLetters()`, `calculateDelay(attempt)`.
## Implementation Notes
The retry counter is 0-indexed: `attempt === 0` is the first call (no delay), `attempt === 1..maxRetries` incur a backoff before the call. `calculateDelay` is public so test suites can pin the schedule against expected values. The injectable `SleepFn` defaults to `setTimeout` — production should keep the default; tests SHOULD inject a no-op sleeper so the suite finishes in milliseconds. The dead-letter queue is in-process — for production durability, register an `onDeadLetter` handler that forwards to a persistent queue. The throw on exhaustion uses `DeliveryFailedError` with a `cause: Error(lastError)` so the upstream `EventBus` listener can introspect the underlying error string.

View File

@@ -0,0 +1,89 @@
# `notification-router` — Primary multi-channel router
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves the target channel (input override → routing rules → default), runs the `notification:routing` hook pipeline, resolves the body / subject (template render or raw), checks the channel is enabled and within rate limit, optionally dispatches to a registered `DeliveryChannel`, persists the notification record, and emits a lifecycle event (`notification.sent` / `notification.failed` / `notification.cancelled` / `notification.created`) on its internal `EventBus`. Every public boundary is Zod-validated.
## Features
- Channel resolution: input override → routing rules (priority and predicate filters) → default channel.
- Template-based content with `{{var}}` substitution.
- Per-channel 60-second window rate limiting (in-process — wire `ChannelRateLimiter` for richer policies).
- Lifecycle events via `EventBus`: `notification.created`, `notification.sent`, `notification.failed`, `notification.cancelled`.
- Optional in-pipeline `DeliveryChannel` dispatch.
- OpenTelemetry meters on every send.
- Zod-validated config at the constructor boundary.
- Injectable `Clock` for deterministic tests.
## Usage: Basic
```ts
import { NotificationRouter } from "@suaveplan/notification-channel";
const router = new NotificationRouter({
channels: [
{ channel: "email", enabled: true },
{ channel: "sms", enabled: true },
],
defaultChannel: "email",
});
const result = await router.send({
recipient: "user@example.com",
body: "Welcome!",
});
```
## Usage: Advanced
```ts
import {
EmailChannel,
NotificationRouter,
} from "@suaveplan/notification-channel";
const email = new EmailChannel({ from: "noreply@x", sendFn: provider.send });
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
templates: [
{
id: "welcome",
name: "Welcome",
channel: "email",
subject: "Welcome {{name}}",
bodyTemplate: "Hi {{name}}",
defaultPriority: "normal",
},
],
routingRules: [
{ id: "urgent-to-sms", name: "u", channels: ["sms"], priority: "urgent" },
],
defaultChannel: "email",
deliveryChannels: [email],
});
router.on("notification.failed", (n) => audit.record(n));
await router.send({
recipient: "u@x",
body: "",
templateId: "welcome",
templateData: { name: "Alice" },
});
```
## API Reference
- `NotificationRouter(config: NotificationRouterConfig)` — constructor.
- `send(input)`, `enqueue(input)`, `sendBatch(inputs)`, `getNotification(id)`, `cancel(id)`, `getChannelStats()`.
- `registerChannel(c)`, `registerTemplate(t)`, `addRoutingRule(r)`, `registerDeliveryChannel(dc)`.
- `on(event, listener)` — subscribe to lifecycle events.
- `NotificationRouterConfig` — `{ channels, templates?, routingRules?, defaultChannel, deliveryChannels?, clock? }`.
## Implementation Notes
The internal 60-second rate-limit window is a fixed bucket per channel — for sliding-window or per-user limits, layer `ChannelRateLimiter` outside the router. `cancel` consults `VALID_TRANSITIONS` so cancelling a `sent` notification rejects (cannot transition `sent → cancelled`). The router uses `EventBus<NotificationEventMap>` so consumers get typed `on(...)` and the underlying envelope semantics — interceptors, abort signals — that ship in `@suaveplan/events`. Construction Zod-parses the config; runtime calls trust the inputs because `send` runs on every notification and re-validating on every call is wasteful — consumers MUST validate `NotificationInput` themselves if they accept it from untrusted sources.

View File

@@ -0,0 +1,64 @@
# `notification-scheduler` — Future-delivery scheduler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. The scheduler does NOT call the router itself — callers register an `onDue(handler)` that receives the original `NotificationInput`, typically wiring it back into `NotificationRouter.send` or `enqueue`. The class exposes both manual `processDue()` (call from cron / queue worker) and `start()`/`stop()` for in-process polling.
## Features
- `schedule(input, deliverAt)` returns a stable id.
- `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)` for inspection.
- `processDue()` awaits the handler per entry and removes processed entries.
- `start()`/`stop()` polling driver with idempotent semantics.
- Injectable `Clock` for deterministic tests.
- Throws `NotificationNotFoundError` for unknown ids on `cancel` / `get`.
## Usage: Basic
```ts
import {
NotificationRouter,
NotificationScheduler,
} from "@suaveplan/notification-channel";
const router = new NotificationRouter({ channels, defaultChannel: "email" });
const scheduler = new NotificationScheduler();
scheduler.onDue(async (input) => {
await router.send(input);
});
scheduler.schedule({ recipient: "u@x", body: "Reminder" }, new Date(Date.now() + 60_000));
scheduler.start();
```
## Usage: Advanced
```ts
const scheduler = new NotificationScheduler(5_000, () => clock.now());
scheduler.onDue(async (input) => {
try {
await router.send(input);
} catch (err) {
await failureQueue.push({ input, err });
}
});
// Hook into a cron worker instead of in-process polling:
cron.schedule("* * * * *", () => {
void scheduler.processDue();
});
```
## API Reference
- `NotificationScheduler(checkIntervalMs?, clock?)` — constructor.
- `schedule(input, deliverAt)`, `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)`, `processDue()`, `start()`, `stop()`, `size`.
- `ScheduledNotification` — `{ id, input, scheduledFor, createdAt, cancelled }`.
- `ScheduleHandler` — `(input: NotificationInput) => Promise<void>`.
## Implementation Notes
`start()` is idempotent — calling twice has no effect; the second call is dropped before `setInterval` runs. `stop()` is idempotent — calling twice is safe and a leading call before `start()` is a no-op. `processDue()` is the single place that mutates the schedule map — both the polling driver and manual / cron callers use it, so behaviour is identical regardless of how processing is triggered. The scheduler does not persist across process restarts — production deployments should layer their own durable store atop `schedule`/`cancel` (e.g. snapshot `listPending()` to disk on shutdown, restore on boot via additional `schedule` calls).

View File

@@ -0,0 +1,49 @@
# `schemas` — Zod schemas at every public boundary
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input before passing it into a constructor or public method. The `NotificationRouter` constructor calls `NotificationRouterConfigSchema.parse(...)` at the boundary; other classes leave validation as an opt-in for the caller because validating on every send is wasteful — the consumer chooses when to enforce.
## Features
- Schemas for every channel config, every public bundle, and every enum: `NotificationChannelSchema`, `NotificationPrioritySchema`, `NotificationStatusSchema`, `RateLimitRuleSchema`, `ChannelConfigSchema`, `RoutingRuleSchema`, `NotificationTemplateSchema`, `TemplateRegistrationSchema`, `NotificationInputSchema`, `NotificationRouterConfigSchema`, `EmailChannelConfigSchema`, `SmsChannelConfigSchema`, `VapidKeysSchema`, `PushChannelConfigSchema`, `WebhookChannelConfigSchema`, `DigestChannelConfigSchema`, `RetryConfigSchema`, `QuietHoursConfigSchema`, `StoredPreferencesSchema`, `PreferenceManagerOptionsSchema`.
- Function-valued fields (`sendFn`, `getSubscriptions`, `fetchFn`, `condition`, `formatDigest`, `store`, `clock`) are typed as `z.unknown()` — Zod cannot introspect callables, and the TypeScript surface is the source of truth for shapes.
- Boundary defaults are NOT injected — schemas validate shape only, defaults live in the consuming classes.
## Usage: Basic
```ts
import { NotificationInputSchema } from "@suaveplan/notification-channel";
const input = NotificationInputSchema.parse(req.body);
await router.send(input);
```
## Usage: Advanced
```ts
import {
NotificationRouterConfigSchema,
StoredPreferencesSchema,
} from "@suaveplan/notification-channel";
// Validate config loaded from a YAML file:
const cfg = NotificationRouterConfigSchema.parse(yaml.parse(text));
const router = new NotificationRouter(cfg);
// Validate stored prefs at the persistence boundary:
const safe = StoredPreferencesSchema.parse(row);
await store.set(safe.userId, safe);
```
## API Reference
Each named export is a `z.ZodType`. Use `.parse(value)` to validate-or-throw, `.safeParse(value)` to validate-or-result. The schemas describe shape only — runtime behaviour, defaults, and engine-pluggable callables remain owned by the consuming classes.
## Implementation Notes
`exactOptionalPropertyTypes: true` in `tsconfig.base.json` makes Zod's `.optional()` behave correctly: an explicit `undefined` value is rejected when the field is absent in the input record, but is accepted on output because the consumer might spread defaults. We use `z.record(z.string(), z.unknown())` for `metadata`-style fields because notification metadata is genuinely free-form. The `RoutingRule.condition` and every `sendFn` / `fetchFn` are `z.unknown()` so YAML-loaded configs (which cannot carry callables) parse cleanly — TypeScript will still complain at the call site if the values are not callable.

View File

@@ -0,0 +1,80 @@
# `template-engine` — Template registry and renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered output. Templates carry a `defaultTemplate` (used when no channel variant matches) plus optional `channelTemplates` keyed by `NotificationChannel`, with `{{var}}` placeholders substituted from a flat `Record<string,string>`. Missing keys are left as-is so QA can spot them.
## Features
- Register / unregister / list templates by string id.
- Per-channel variants (`channelTemplates.email`, `.sms`, etc.) with fallback to `defaultTemplate`.
- `{{var}}` placeholder substitution against a flat string-valued record.
- Throws `TemplateNotFoundError` for unknown ids — no silent empty renders.
- Universal — no DOM/Node-specific APIs.
## Usage: Basic
```ts
import { TemplateEngine } from "@suaveplan/notification-channel";
const engine = new TemplateEngine();
engine.register({
id: "welcome",
name: "Welcome",
defaultTemplate: {
subject: "Welcome {{name}}",
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
},
});
const rendered = engine.render("welcome", { name: "Alice", app: "Suaveplan" });
```
## Usage: Advanced
```ts
engine.register({
id: "order-status",
name: "Order status",
defaultTemplate: {
subject: "Order {{orderId}} — {{status}}",
bodyTemplate: "Order #{{orderId}}: {{status}}",
},
channelTemplates: {
sms: { bodyTemplate: "Order {{orderId}}: {{status}}" },
email: {
subject: "Order {{orderId}} update",
bodyTemplate: "<h1>Order {{orderId}}</h1><p>{{status}}</p>",
},
},
});
const sms = engine.render(
"order-status",
{ orderId: "12345", status: "shipped" },
"sms"
);
const email = engine.render(
"order-status",
{ orderId: "12345", status: "shipped" },
"email"
);
```
## API Reference
- `TemplateEngine` class.
- `register(t: TemplateRegistration): void`
- `unregister(id: string): boolean`
- `has(id: string): boolean`
- `listIds(): string[]`
- `render(id, data?, channel?): RenderedTemplate`
- `TemplateRegistration`, `ChannelTemplateBody`, `ChannelTemplate`, `RenderedTemplate`.
## Implementation Notes
Substitution uses `String.prototype.split(...).join(...)` rather than `replace(/.../g, ...)` so dollar-escape sequences in the value (`$&`, `$1`) flow through verbatim. Subjects flow through the same substitution pass when set. Unknown placeholders are intentionally preserved — silently dropping them masks integration mismatches that QA would otherwise catch. The engine is intentionally simple: no conditionals, no loops, no template inheritance. Consumers needing those features should plug a real template language (Handlebars, Liquid, MJML) into a custom `TemplateEngine` decorator or render upstream.