Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,170 @@
{
"name": "@suaveplan/notification-channel",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./types/notification": {
"import": {
"types": "./dist/types/notification.types.d.ts",
"default": "./dist/types/notification.types.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./hooks/hooks": {
"import": {
"types": "./dist/hooks/hooks.d.ts",
"default": "./dist/hooks/hooks.js"
}
},
"./channels/delivery-channel": {
"import": {
"types": "./dist/channels/delivery-channel.d.ts",
"default": "./dist/channels/delivery-channel.js"
}
},
"./channels/email-channel": {
"import": {
"types": "./dist/channels/email-channel.d.ts",
"default": "./dist/channels/email-channel.js"
}
},
"./channels/sms-channel": {
"import": {
"types": "./dist/channels/sms-channel.d.ts",
"default": "./dist/channels/sms-channel.js"
}
},
"./channels/push-channel": {
"import": {
"types": "./dist/channels/push-channel.d.ts",
"default": "./dist/channels/push-channel.js"
}
},
"./channels/in-app-channel": {
"import": {
"types": "./dist/channels/in-app-channel.d.ts",
"default": "./dist/channels/in-app-channel.js"
}
},
"./channels/webhook-channel": {
"import": {
"types": "./dist/channels/webhook-channel.d.ts",
"default": "./dist/channels/webhook-channel.js"
}
},
"./digest/digest-channel": {
"import": {
"types": "./dist/digest/digest-channel.d.ts",
"default": "./dist/digest/digest-channel.js"
}
},
"./templates/template-engine": {
"import": {
"types": "./dist/templates/template-engine.d.ts",
"default": "./dist/templates/template-engine.js"
}
},
"./rate-limiter/channel-rate-limiter": {
"import": {
"types": "./dist/rate-limiter/channel-rate-limiter.d.ts",
"default": "./dist/rate-limiter/channel-rate-limiter.js"
}
},
"./retry/delivery-retry": {
"import": {
"types": "./dist/retry/delivery-retry.d.ts",
"default": "./dist/retry/delivery-retry.js"
}
},
"./scheduler/notification-scheduler": {
"import": {
"types": "./dist/scheduler/notification-scheduler.d.ts",
"default": "./dist/scheduler/notification-scheduler.js"
}
},
"./preferences/preference-manager": {
"import": {
"types": "./dist/preferences/preference-manager.d.ts",
"default": "./dist/preferences/preference-manager.js"
}
},
"./router/notification-router": {
"import": {
"types": "./dist/router/notification-router.d.ts",
"default": "./dist/router/notification-router.js"
}
},
"./otel/metrics": {
"import": {
"types": "./dist/otel/metrics.d.ts",
"default": "./dist/otel/metrics.js"
}
},
"./fakes/fake-fetch": {
"import": {
"types": "./dist/fakes/fake-fetch.d.ts",
"default": "./dist/fakes/fake-fetch.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,25 @@
import type { Notification } from "../types/notification.types.js";
/** Result of delivering a notification through a channel. */
export interface DeliveryResult {
readonly success: boolean;
readonly channel: string;
readonly messageId?: string;
readonly error?: string;
readonly timestamp: string;
}
/**
* Contract every concrete delivery channel implements. Channels are
* intentionally small — `send` MUST swallow transport-level exceptions
* and return a `DeliveryResult` so the router can decide whether to
* retry, dead-letter, or surface the failure to the bus.
*/
export interface DeliveryChannel {
/** Unique name identifying this channel. */
readonly name: string;
/** Send a notification through this channel. */
send(notification: Notification): Promise<DeliveryResult>;
/** Check whether this channel can handle the given notification. */
canSend(notification: Notification): boolean;
}

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,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Notification } from "../types/notification.types.js";
import { EmailChannel } from "./email-channel.js";
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif_1",
channel: "email",
recipient: "user@example.com",
subject: "Test Subject",
body: "Test body",
priority: "normal",
status: "sending",
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("EmailChannel", () => {
it('has name "email"', { timeout: 5000 }, () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_1" }),
});
expect(channel.name).toBe("email");
});
it("canSend returns true for email channel with @ recipient", {
timeout: 5000,
}, () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_1" }),
});
expect(channel.canSend(makeNotification())).toBe(true);
});
it("canSend returns false for non-email channel", { timeout: 5000 }, () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_1" }),
});
expect(channel.canSend(makeNotification({ channel: "sms" }))).toBe(
false
);
});
it("canSend returns false for recipient without @", {
timeout: 5000,
}, () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_1" }),
});
expect(
channel.canSend(makeNotification({ recipient: "no-at-sign" }))
).toBe(false);
});
it("sends successfully and returns result", {
timeout: 30000,
}, async () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_42" }),
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.channel).toBe("email");
expect(result.messageId).toBe("msg_42");
expect(result.timestamp).toBeDefined();
});
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => {
throw new Error("SMTP connection failed");
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("SMTP connection failed");
});
it('uses "(No subject)" when subject is missing', {
timeout: 30000,
}, async () => {
let capturedSubject = "";
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => {
capturedSubject = msg.subject;
return { messageId: "msg_1" };
},
});
const n = makeNotification();
const { subject: _drop, ...rest } = n;
await channel.send(rest as Notification);
expect(capturedSubject).toBe("(No subject)");
});
it("passes metadata to sendFn", { timeout: 30000 }, async () => {
let capturedMeta: Record<string, unknown> | undefined;
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => {
capturedMeta = msg.metadata;
return { messageId: "msg_1" };
},
});
await channel.send(makeNotification({ metadata: { key: "val" } }));
expect(capturedMeta).toEqual({ key: "val" });
});
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => {
const literal: unknown = "string error";
throw literal;
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("string error");
});
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async () => ({ messageId: "msg_1" }),
clock: () => Date.UTC(2026, 0, 2, 3, 4, 5),
});
const result = await channel.send(makeNotification());
expect(result.timestamp).toBe("2026-01-02T03:04:05.000Z");
});
});

View File

@@ -0,0 +1,72 @@
import type { Clock } from "@suaveplan/types";
import type { Notification } from "../types/notification.types.js";
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
/** Email message passed to the send function. */
export interface EmailMessage {
readonly to: string;
readonly subject: string;
readonly body: string;
readonly metadata?: Record<string, unknown>;
}
/** Configuration for the email channel. */
export interface EmailChannelConfig {
readonly from: string;
readonly sendFn: (message: EmailMessage) => Promise<{ messageId: string }>;
/** Optional clock for `timestamp` field (default: `Date.now`). */
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* Delivers notifications via email using a configurable send function.
* The `sendFn` is the integration seam — wire a real provider (SES,
* SendGrid, Postmark) by implementing the callback signature.
*/
export class EmailChannel implements DeliveryChannel {
public readonly name = "email";
private readonly config: EmailChannelConfig;
private readonly clock: Clock;
public constructor(config: EmailChannelConfig) {
this.config = config;
this.clock = config.clock ?? defaultClock;
}
public canSend(notification: Notification): boolean {
return (
notification.channel === "email" &&
notification.recipient.includes("@")
);
}
public async send(notification: Notification): Promise<DeliveryResult> {
const message: EmailMessage = {
to: notification.recipient,
subject: notification.subject ?? "(No subject)",
body: notification.body,
...(notification.metadata !== undefined
? { metadata: notification.metadata }
: {}),
};
try {
const result = await this.config.sendFn(message);
return {
success: true,
channel: this.name,
messageId: result.messageId,
timestamp: new Date(this.clock()).toISOString(),
};
} catch (err) {
return {
success: false,
channel: this.name,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date(this.clock()).toISOString(),
};
}
}
}

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,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Notification } from "../types/notification.types.js";
import type { InAppNotification, InAppStore } from "./in-app-channel.js";
import { InAppChannel } from "./in-app-channel.js";
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif_1",
channel: "in_app",
recipient: "user123",
subject: "Test",
body: "In-app message",
priority: "normal",
status: "sending",
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("InAppChannel", () => {
it('has name "in_app"', { timeout: 5000 }, () => {
const channel = new InAppChannel();
expect(channel.name).toBe("in_app");
});
it("canSend returns true for in_app channel", { timeout: 5000 }, () => {
const channel = new InAppChannel();
expect(channel.canSend(makeNotification())).toBe(true);
});
it("canSend returns false for non in_app channel", {
timeout: 5000,
}, () => {
const channel = new InAppChannel();
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
false
);
});
it("sends and stores notification in memory", {
timeout: 30000,
}, async () => {
const channel = new InAppChannel();
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.messageId).toBe("notif_1");
const stored = await channel.getByRecipient("user123");
expect(stored).toHaveLength(1);
const first = stored[0];
expect(first?.body).toBe("In-app message");
});
it("stores multiple notifications per recipient", {
timeout: 30000,
}, async () => {
const channel = new InAppChannel();
await channel.send(makeNotification({ id: "n1" }));
await channel.send(makeNotification({ id: "n2", body: "Second" }));
const stored = await channel.getByRecipient("user123");
expect(stored).toHaveLength(2);
});
it("returns empty array for unknown recipient", {
timeout: 30000,
}, async () => {
const channel = new InAppChannel();
const stored = await channel.getByRecipient("unknown");
expect(stored).toHaveLength(0);
});
it("marks notification as read", { timeout: 30000 }, async () => {
const channel = new InAppChannel();
await channel.send(makeNotification());
await channel.markRead("notif_1");
const stored = await channel.getByRecipient("user123");
expect(stored[0]?.read).toBe(true);
});
it("markRead on unknown id is a no-op", { timeout: 5000 }, async () => {
const channel = new InAppChannel();
await channel.send(makeNotification());
await channel.markRead("does-not-exist");
const stored = await channel.getByRecipient("user123");
expect(stored[0]?.read).toBe(false);
});
it("uses custom store when provided", { timeout: 30000 }, async () => {
const records: InAppNotification[] = [];
const customStore: InAppStore = {
async save(n) {
records.push(n);
},
async getByRecipient() {
return records;
},
async markRead() {
/* noop */
},
};
const channel = new InAppChannel(customStore);
await channel.send(makeNotification());
expect(records).toHaveLength(1);
});
it("handles store save failure gracefully", {
timeout: 30000,
}, async () => {
const failingStore: InAppStore = {
async save() {
throw new Error("DB write failed");
},
async getByRecipient() {
return [];
},
async markRead() {
/* noop */
},
};
const channel = new InAppChannel(failingStore);
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("DB write failed");
});
it("returns string body when store throws non-Error", {
timeout: 5000,
}, async () => {
const failingStore: InAppStore = {
async save() {
const literal: unknown = "boom";
throw literal;
},
async getByRecipient() {
return [];
},
async markRead() {
/* noop */
},
};
const channel = new InAppChannel(failingStore);
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("boom");
});
it("notification stored as unread by default", {
timeout: 30000,
}, async () => {
const channel = new InAppChannel();
await channel.send(makeNotification());
const stored = await channel.getByRecipient("user123");
expect(stored[0]?.read).toBe(false);
});
it("preserves metadata when notification carries it", {
timeout: 5000,
}, async () => {
const channel = new InAppChannel();
await channel.send(makeNotification({ metadata: { trace: "x-1" } }));
const stored = await channel.getByRecipient("user123");
expect(stored[0]?.metadata).toEqual({ trace: "x-1" });
});
it("supports notifications without metadata and without subject", {
timeout: 5000,
}, async () => {
const channel = new InAppChannel();
const { subject: _drop, ...rest } = makeNotification();
await channel.send(rest as Notification);
const stored = await channel.getByRecipient("user123");
expect(stored[0]?.subject).toBeUndefined();
});
});

View File

@@ -0,0 +1,115 @@
import type { Clock } from "@suaveplan/types";
import type { Notification } from "../types/notification.types.js";
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
/** Stored in-app notification record. */
export interface InAppNotification {
readonly id: string;
readonly recipientId: string;
readonly subject?: string;
readonly body: string;
readonly read: boolean;
readonly createdAt: string;
readonly metadata?: Record<string, unknown>;
}
/** Optional store interface for persistence. */
export interface InAppStore {
save(notification: InAppNotification): Promise<void>;
getByRecipient(recipientId: string): Promise<InAppNotification[]>;
markRead(id: string): Promise<void>;
}
const defaultClock: Clock = () => Date.now();
/**
* Delivers notifications in-app by storing them in memory (default) or
* a caller-provided `InAppStore` implementation. Suitable for unread
* badges, in-app inboxes, and per-user notification feeds.
*/
export class InAppChannel implements DeliveryChannel {
public readonly name = "in_app";
private readonly store: InAppStore;
private readonly clock: Clock;
private readonly memoryStore = new Map<string, InAppNotification[]>();
public constructor(store?: InAppStore, clock: Clock = defaultClock) {
this.clock = clock;
this.store = store ?? this.createMemoryStore();
}
public canSend(notification: Notification): boolean {
return notification.channel === "in_app";
}
public async send(notification: Notification): Promise<DeliveryResult> {
const record: InAppNotification = {
id: notification.id,
recipientId: notification.recipient,
...(notification.subject !== undefined
? { subject: notification.subject }
: {}),
body: notification.body,
read: false,
createdAt: new Date(this.clock()).toISOString(),
...(notification.metadata !== undefined
? { metadata: notification.metadata }
: {}),
};
try {
await this.store.save(record);
return {
success: true,
channel: this.name,
messageId: notification.id,
timestamp: new Date(this.clock()).toISOString(),
};
} catch (err) {
return {
success: false,
channel: this.name,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date(this.clock()).toISOString(),
};
}
}
/** Get notifications for a recipient (convenience). */
public async getByRecipient(
recipientId: string
): Promise<InAppNotification[]> {
return this.store.getByRecipient(recipientId);
}
/** Mark a notification as read (convenience). */
public async markRead(id: string): Promise<void> {
return this.store.markRead(id);
}
private createMemoryStore(): InAppStore {
const store = this.memoryStore;
return {
async save(notification: InAppNotification): Promise<void> {
const list = store.get(notification.recipientId) ?? [];
list.push(notification);
store.set(notification.recipientId, list);
},
async getByRecipient(
recipientId: string
): Promise<InAppNotification[]> {
return store.get(recipientId) ?? [];
},
async markRead(id: string): Promise<void> {
for (const list of store.values()) {
const existing = list.find((n) => n.id === id);
if (existing !== undefined) {
const idx = list.indexOf(existing);
list.splice(idx, 1, { ...existing, read: true });
return;
}
}
},
};
}
}

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,366 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeFetch } from "../fakes/fake-fetch.js";
import type { Notification } from "../types/notification.types.js";
import { PushChannel } from "./push-channel.js";
import type { PushSubscription, VapidKeys } from "./push-channel.types.js";
const vapid: VapidKeys = {
publicKey: "BPubKey123",
privateKey: "PrivKey456",
subject: "mailto:admin@example.com",
};
function makeSub(endpoint = "https://push.example.com/sub1"): PushSubscription {
return {
endpoint,
keys: { p256dh: "p256dh-key", auth: "auth-key" },
};
}
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif-1",
channel: "push",
recipient: "user-1",
subject: "Test Push",
body: "Hello push",
priority: "normal",
status: "pending",
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("PushChannel", () => {
it("canSend returns true for push channel notifications", {
timeout: 5000,
}, () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
});
expect(channel.canSend(makeNotification())).toBe(true);
});
it("canSend returns false for non-push channel notifications", {
timeout: 5000,
}, () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
});
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
false
);
});
it("canSend returns false when recipient is empty", {
timeout: 5000,
}, () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
});
expect(channel.canSend(makeNotification({ recipient: "" }))).toBe(
false
);
});
it("delivers to a single subscription successfully", {
timeout: 5000,
}, async () => {
const sent: { sub: PushSubscription; payload: string }[] = [];
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
sendFn: async (sub, payload) => {
sent.push({ sub, payload });
return { success: true, statusCode: 201 };
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.channel).toBe("push");
expect(result.messageId).toContain("push-notif-1");
expect(sent).toHaveLength(1);
const parsed = JSON.parse(sent[0]?.payload ?? "{}");
expect(parsed.title).toBe("Test Push");
expect(parsed.body).toBe("Hello push");
expect(parsed.tag).toBe("notif-1");
});
it("delivers to multiple subscriptions", { timeout: 5000 }, async () => {
let sendCount = 0;
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [
makeSub("https://push.example.com/sub1"),
makeSub("https://push.example.com/sub2"),
makeSub("https://push.example.com/sub3"),
],
sendFn: async () => {
sendCount++;
return { success: true, statusCode: 201 };
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.messageId).toContain("3of3");
expect(sendCount).toBe(3);
});
it("reports partial success correctly", { timeout: 5000 }, async () => {
let callIndex = 0;
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [
makeSub("https://push.example.com/sub1"),
makeSub("https://push.example.com/sub2"),
],
sendFn: async () => {
callIndex++;
if (callIndex === 1) return { success: true, statusCode: 201 };
return { success: false, statusCode: 410 };
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.messageId).toContain("1of2");
});
it("returns failure when all subscriptions fail with HTTP errors", {
timeout: 5000,
}, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [
makeSub(),
makeSub("https://push.example.com/sub2"),
],
sendFn: async () => ({ success: false, statusCode: 500 }),
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("All 2 subscriptions failed");
expect(result.error).toContain("HTTP 500");
});
it("returns failure when no subscriptions found", {
timeout: 5000,
}, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [],
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("No push subscriptions");
});
it("handles rejected promises in subscriptions", {
timeout: 5000,
}, async () => {
let callIndex = 0;
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [
makeSub(),
makeSub("https://push.example.com/sub2"),
],
sendFn: async () => {
callIndex++;
if (callIndex === 1) throw new Error("Network timeout");
return { success: true, statusCode: 201 };
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.messageId).toContain("1of2");
});
it("handles all subscriptions throwing errors", {
timeout: 5000,
}, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
sendFn: async () => {
throw new Error("Connection refused");
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("Connection refused");
});
it("handles non-Error rejection with default message", {
timeout: 5000,
}, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
sendFn: async () => {
const literal: unknown = { foo: "bar" };
throw literal;
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown error");
});
it("handles getSubscriptions throwing", { timeout: 5000 }, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => {
throw new Error("DB unavailable");
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("DB unavailable");
});
it("handles getSubscriptions throwing non-Error", {
timeout: 5000,
}, async () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => {
const literal: unknown = 7;
throw literal;
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("7");
});
it("uses default subject when none provided", {
timeout: 5000,
}, async () => {
let capturedPayload = "";
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
sendFn: async (_sub, payload) => {
capturedPayload = payload;
return { success: true, statusCode: 201 };
},
});
const { subject: _drop, ...rest } = makeNotification();
await channel.send(rest as Notification);
const parsed = JSON.parse(capturedPayload);
expect(parsed.title).toBe("Notification");
});
it("includes metadata in payload data field", {
timeout: 5000,
}, async () => {
let capturedPayload = "";
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
sendFn: async (_sub, payload) => {
capturedPayload = payload;
return { success: true, statusCode: 201 };
},
});
await channel.send(
makeNotification({
metadata: { action: "open", url: "/dashboard" },
})
);
const parsed = JSON.parse(capturedPayload);
expect(parsed.data).toEqual({ action: "open", url: "/dashboard" });
});
it("respects custom TTL config", { timeout: 5000 }, () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [],
ttl: 3600,
});
expect(channel.name).toBe("push");
});
it("uses default fetchFn against VAPID endpoint when sendFn omitted", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 201 });
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
fetchFn: fake.fetch,
});
const r = await channel.send(makeNotification());
expect(r.success).toBe(true);
const first = fake.calls[0];
expect(first?.url).toBe("https://push.example.com/sub1");
const headers = (first?.init?.headers ?? {}) as Record<string, string>;
expect(headers["Authorization"]).toContain("vapid");
});
it("omits Authorization header when vapid.subject is empty", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 201 });
const channel = new PushChannel({
vapid: { publicKey: "p", privateKey: "k", subject: "" },
getSubscriptions: async () => [makeSub()],
fetchFn: fake.fetch,
});
await channel.send(makeNotification());
const headers = (fake.calls[0]?.init?.headers ?? {}) as Record<
string,
string
>;
expect(headers["Authorization"]).toBeUndefined();
});
it("default fetchFn reports HTTP non-2xx as failure", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 410 });
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [makeSub()],
fetchFn: fake.fetch,
});
const r = await channel.send(makeNotification());
expect(r.success).toBe(false);
expect(r.error).toContain("HTTP 410");
});
it('has name "push"', { timeout: 5000 }, () => {
const channel = new PushChannel({
vapid,
getSubscriptions: async () => [],
});
expect(channel.name).toBe("push");
});
});

View File

@@ -0,0 +1,163 @@
import type { Clock, FetchLike } from "@suaveplan/types";
import type { Notification } from "../types/notification.types.js";
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
import type {
PushChannelConfig,
PushPayload,
PushSendOutcome,
PushSubscription,
VapidKeys,
} from "./push-channel.types.js";
const defaultClock: Clock = () => Date.now();
async function defaultSendPush(
subscription: PushSubscription,
payload: string,
vapid: VapidKeys,
ttl: number,
fetchFn: FetchLike
): Promise<PushSendOutcome> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Content-Encoding": "aes128gcm",
TTL: String(ttl),
Urgency: "normal",
};
if (vapid.subject) {
headers["Authorization"] =
`vapid t=${vapid.publicKey}, k=${vapid.privateKey}`;
}
const response = await fetchFn(subscription.endpoint, {
method: "POST",
headers,
body: payload,
});
return {
success: response.status >= 200 && response.status < 300,
statusCode: response.status,
};
}
/**
* Delivers notifications via the Web Push Protocol (RFC 8030). The
* channel is intentionally provider-neutral: the `sendFn` callback OR
* the injected `fetchFn` performs the VAPID-authenticated POST against
* each subscription's endpoint, and the channel aggregates partial
* success/failure into a single `DeliveryResult`.
*/
export class PushChannel implements DeliveryChannel {
public readonly name = "push";
private readonly config: PushChannelConfig;
private readonly ttl: number;
private readonly clock: Clock;
private readonly fetchFn: FetchLike;
public constructor(config: PushChannelConfig) {
this.config = config;
this.ttl = config.ttl ?? 86_400;
this.clock = config.clock ?? defaultClock;
this.fetchFn = config.fetchFn ?? globalThis.fetch.bind(globalThis);
}
public canSend(notification: Notification): boolean {
return notification.channel === "push" && notification.recipient !== "";
}
public async send(notification: Notification): Promise<DeliveryResult> {
try {
const subscriptions = await this.config.getSubscriptions(
notification.recipient
);
if (subscriptions.length === 0) {
return this.fail(
"No push subscriptions found for recipient",
notification
);
}
const payload = this.buildPayload(notification);
const payloadStr = JSON.stringify(payload);
const results = await Promise.allSettled(
subscriptions.map((sub) =>
this.sendToSubscription(sub, payloadStr)
)
);
const successes = results.filter(
(r) => r.status === "fulfilled" && r.value.success
);
if (successes.length === 0) {
const errors = results
.map((r) => {
if (r.status === "rejected") {
const cause = r.reason as { message?: string };
return cause.message ?? "Unknown error";
}
return `HTTP ${r.value.statusCode}`;
})
.filter(Boolean);
return this.fail(
`All ${subscriptions.length} subscriptions failed: ${errors.join(", ")}`,
notification
);
}
return {
success: true,
channel: this.name,
messageId: `push-${notification.id}-${successes.length}of${subscriptions.length}`,
timestamp: new Date(this.clock()).toISOString(),
};
} catch (err) {
return this.fail(
err instanceof Error ? err.message : String(err),
notification
);
}
}
private fail(error: string, _notification: Notification): DeliveryResult {
return {
success: false,
channel: this.name,
error,
timestamp: new Date(this.clock()).toISOString(),
};
}
private buildPayload(notification: Notification): PushPayload {
const payload: {
-readonly [K in keyof PushPayload]: PushPayload[K];
} = {
title: notification.subject ?? "Notification",
body: notification.body,
tag: notification.id,
};
if (notification.metadata !== undefined) {
payload.data = notification.metadata;
}
return payload;
}
private async sendToSubscription(
subscription: PushSubscription,
payload: string
): Promise<PushSendOutcome> {
if (this.config.sendFn !== undefined) {
return this.config.sendFn(subscription, payload, this.config.vapid);
}
return defaultSendPush(
subscription,
payload,
this.config.vapid,
this.ttl,
this.fetchFn
);
}
}

View File

@@ -0,0 +1,67 @@
import type { Clock, FetchLike } from "@suaveplan/types";
/** VAPID key pair for Web Push authentication. */
export interface VapidKeys {
readonly publicKey: string;
readonly privateKey: string;
readonly subject: string;
}
/** A web push subscription (from the browser's PushManager). */
export interface PushSubscription {
readonly endpoint: string;
readonly keys: {
readonly p256dh: string;
readonly auth: string;
};
readonly expirationTime?: number | null;
}
/** Action button for push notifications. */
export interface PushAction {
readonly action: string;
readonly title: string;
readonly icon?: string;
}
/** Push notification payload. */
export interface PushPayload {
readonly title: string;
readonly body: string;
readonly icon?: string;
readonly badge?: string;
readonly image?: string;
readonly tag?: string;
readonly data?: Record<string, unknown>;
readonly actions?: readonly PushAction[];
readonly requireInteraction?: boolean;
readonly silent?: boolean;
}
/** Outcome returned by the optional injected `sendFn`. */
export interface PushSendOutcome {
readonly success: boolean;
readonly statusCode: number;
}
/** Configuration for the push channel. */
export interface PushChannelConfig {
/** VAPID keys for authentication. */
readonly vapid: VapidKeys;
/** Function to look up push subscriptions for a recipient. */
readonly getSubscriptions: (
recipient: string
) => Promise<readonly PushSubscription[]>;
/** Optional function to send the actual push message. */
readonly sendFn?: (
subscription: PushSubscription,
payload: string,
vapid: VapidKeys
) => Promise<PushSendOutcome>;
/** Optional fetch implementation used when `sendFn` is omitted. */
readonly fetchFn?: FetchLike;
/** TTL in seconds for push messages (default: 86400). */
readonly ttl?: number;
/** Optional clock for the `timestamp` field. */
readonly clock?: Clock;
}

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,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Notification } from "../types/notification.types.js";
import { SmsChannel } from "./sms-channel.js";
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif_1",
channel: "sms",
recipient: "+15551234567",
body: "Test SMS body",
priority: "normal",
status: "sending",
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("SmsChannel", () => {
it('has name "sms"', { timeout: 5000 }, () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_1" }),
});
expect(channel.name).toBe("sms");
});
it("canSend returns true for sms channel with + recipient", {
timeout: 5000,
}, () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_1" }),
});
expect(channel.canSend(makeNotification())).toBe(true);
});
it("canSend returns false for non-sms channel", { timeout: 5000 }, () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_1" }),
});
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
false
);
});
it("canSend returns false for recipient without +", {
timeout: 5000,
}, () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_1" }),
});
expect(
channel.canSend(makeNotification({ recipient: "5551234567" }))
).toBe(false);
});
it("sends successfully and returns result", {
timeout: 30000,
}, async () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_42" }),
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.channel).toBe("sms");
expect(result.messageId).toBe("sms_42");
expect(result.timestamp).toBeDefined();
});
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => {
throw new Error("SMS provider down");
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("SMS provider down");
});
it("passes body and metadata to sendFn", { timeout: 30000 }, async () => {
let capturedBody = "";
let capturedMeta: Record<string, unknown> | undefined;
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async (msg) => {
capturedBody = msg.body;
capturedMeta = msg.metadata;
return { messageId: "sms_1" };
},
});
await channel.send(
makeNotification({ body: "Hello SMS", metadata: { tag: "urgent" } })
);
expect(capturedBody).toBe("Hello SMS");
expect(capturedMeta).toEqual({ tag: "urgent" });
});
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => {
const literal: unknown = 42;
throw literal;
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("42");
});
it("honours injected clock", { timeout: 5000 }, async () => {
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async () => ({ messageId: "sms_1" }),
clock: () => Date.UTC(2026, 0, 1),
});
const r = await channel.send(makeNotification());
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
});
});

View File

@@ -0,0 +1,70 @@
import type { Clock } from "@suaveplan/types";
import type { Notification } from "../types/notification.types.js";
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
/** SMS message passed to the send function. */
export interface SmsMessage {
readonly to: string;
readonly body: string;
readonly metadata?: Record<string, unknown>;
}
/** Configuration for the SMS channel. */
export interface SmsChannelConfig {
readonly from: string;
readonly sendFn: (message: SmsMessage) => Promise<{ messageId: string }>;
/** Optional clock for `timestamp` field (default: `Date.now`). */
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* Delivers notifications via SMS using a configurable send function.
* The `sendFn` is the integration seam — wire Twilio / MessageBird /
* Vonage by implementing the callback signature.
*/
export class SmsChannel implements DeliveryChannel {
public readonly name = "sms";
private readonly config: SmsChannelConfig;
private readonly clock: Clock;
public constructor(config: SmsChannelConfig) {
this.config = config;
this.clock = config.clock ?? defaultClock;
}
public canSend(notification: Notification): boolean {
return (
notification.channel === "sms" &&
notification.recipient.startsWith("+")
);
}
public async send(notification: Notification): Promise<DeliveryResult> {
const message: SmsMessage = {
to: notification.recipient,
body: notification.body,
...(notification.metadata !== undefined
? { metadata: notification.metadata }
: {}),
};
try {
const result = await this.config.sendFn(message);
return {
success: true,
channel: this.name,
messageId: result.messageId,
timestamp: new Date(this.clock()).toISOString(),
};
} catch (err) {
return {
success: false,
channel: this.name,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date(this.clock()).toISOString(),
};
}
}
}

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,153 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeFetch } from "../fakes/fake-fetch.js";
import type { Notification } from "../types/notification.types.js";
import { WebhookChannel } from "./webhook-channel.js";
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif_1",
channel: "webhook",
recipient: "https://hooks.example.com/callback",
subject: "Test Hook",
body: "Webhook payload",
priority: "normal",
status: "sending",
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("WebhookChannel", () => {
it('has name "webhook"', { timeout: 5000 }, () => {
const channel = new WebhookChannel();
expect(channel.name).toBe("webhook");
});
it("canSend returns true for webhook channel with https URL", {
timeout: 5000,
}, () => {
const channel = new WebhookChannel();
expect(channel.canSend(makeNotification())).toBe(true);
});
it("canSend returns true for http URL", { timeout: 5000 }, () => {
const channel = new WebhookChannel();
expect(
channel.canSend(
makeNotification({ recipient: "http://hooks.example.com/cb" })
)
).toBe(true);
});
it("canSend returns false for non-webhook channel", {
timeout: 5000,
}, () => {
const channel = new WebhookChannel();
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
false
);
});
it("canSend returns false for non-URL recipient", { timeout: 5000 }, () => {
const channel = new WebhookChannel();
expect(
channel.canSend(makeNotification({ recipient: "not-a-url" }))
).toBe(false);
});
it("sends successfully with 200 response", { timeout: 30000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({ fetchFn: fake.fetch });
const result = await channel.send(makeNotification());
expect(result.success).toBe(true);
expect(result.channel).toBe("webhook");
expect(result.messageId).toBe("notif_1");
const first = fake.calls[0];
expect(first?.url).toBe("https://hooks.example.com/callback");
});
it("returns failure for non-ok response", { timeout: 30000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 500 });
const channel = new WebhookChannel({ fetchFn: fake.fetch });
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toContain("500");
});
it("returns failure when fetch throws", { timeout: 30000 }, async () => {
const channel = new WebhookChannel({
fetchFn: async () => {
throw new Error("Network error");
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("Network error");
});
it("sends correct payload in POST body", { timeout: 30000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({ fetchFn: fake.fetch });
await channel.send(makeNotification());
const init = fake.calls[0]?.init;
const body = init?.body;
expect(typeof body).toBe("string");
const parsed = JSON.parse(body as string);
expect(parsed.id).toBe("notif_1");
expect(parsed.body).toBe("Webhook payload");
});
it("includes custom headers", { timeout: 30000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({
headers: { Authorization: "Bearer token123" },
fetchFn: fake.fetch,
});
await channel.send(makeNotification());
const init = fake.calls[0]?.init;
const headers = init?.headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer token123");
expect(headers["Content-Type"]).toBe("application/json");
});
it("handles non-Error throw", { timeout: 30000 }, async () => {
const channel = new WebhookChannel({
fetchFn: async () => {
const literal: unknown = "fetch failed";
throw literal;
},
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("fetch failed");
});
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({
fetchFn: fake.fetch,
clock: () => Date.UTC(2026, 0, 1),
});
const r = await channel.send(makeNotification());
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
});
it("respects custom timeout via AbortController", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ pending: true });
const channel = new WebhookChannel({
fetchFn: fake.fetch,
timeoutMs: 5,
});
const result = await channel.send(makeNotification());
expect(result.success).toBe(false);
expect(result.error).toBe("aborted");
});
});

View File

@@ -0,0 +1,96 @@
import type { Clock, FetchLike } from "@suaveplan/types";
import type { Notification } from "../types/notification.types.js";
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
/** Configuration for the webhook channel. */
export interface WebhookChannelConfig {
/** Default headers added to every request. */
readonly headers?: Readonly<Record<string, string>>;
/** Optional `FetchLike` implementation (default: `globalThis.fetch`). */
readonly fetchFn?: FetchLike;
/** Timeout in milliseconds (default: 10_000). */
readonly timeoutMs?: number;
/** Optional clock for the `timestamp` field (default: `Date.now`). */
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* Delivers notifications via HTTP webhook POST requests. The
* notification's `recipient` field is the destination URL; the body
* contains the serialised notification payload.
*/
export class WebhookChannel implements DeliveryChannel {
public readonly name = "webhook";
private readonly config: WebhookChannelConfig;
private readonly clock: Clock;
private readonly fetchFn: FetchLike;
public constructor(config?: WebhookChannelConfig) {
this.config = config ?? {};
this.clock = this.config.clock ?? defaultClock;
this.fetchFn = this.config.fetchFn ?? globalThis.fetch.bind(globalThis);
}
public canSend(notification: Notification): boolean {
return (
notification.channel === "webhook" &&
(notification.recipient.startsWith("http://") ||
notification.recipient.startsWith("https://"))
);
}
public async send(notification: Notification): Promise<DeliveryResult> {
const timeoutMs = this.config.timeoutMs ?? 10_000;
const payload = {
id: notification.id,
subject: notification.subject,
body: notification.body,
priority: notification.priority,
metadata: notification.metadata,
createdAt: notification.createdAt,
};
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await this.fetchFn(notification.recipient, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.config.headers,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
return {
success: false,
channel: this.name,
error: `HTTP ${response.status}: ${response.statusText}`,
timestamp: new Date(this.clock()).toISOString(),
};
}
return {
success: true,
channel: this.name,
messageId: notification.id,
timestamp: new Date(this.clock()).toISOString(),
};
} catch (err) {
return {
success: false,
channel: this.name,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date(this.clock()).toISOString(),
};
}
}
}

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,352 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
DeliveryChannel,
DeliveryResult,
} from "../channels/delivery-channel.js";
import type { Notification } from "../types/notification.types.js";
import { DigestChannel } from "./digest-channel.js";
let counter = 0;
function makeNotification(overrides: Partial<Notification> = {}): Notification {
counter++;
return {
id: `notif-${counter}`,
channel: "email",
recipient: "user@example.com",
subject: "Test",
body: "Hello",
priority: "normal",
status: "pending",
createdAt: new Date().toISOString(),
...overrides,
};
}
function makeMockDelegate(): DeliveryChannel & {
sentNotifications: Notification[];
} {
const sentNotifications: Notification[] = [];
return {
name: "mock-email",
sentNotifications,
canSend: () => true,
send: async (notification: Notification): Promise<DeliveryResult> => {
sentNotifications.push(notification);
return {
success: true,
channel: "mock-email",
messageId: `msg-${notification.id}`,
timestamp: new Date().toISOString(),
};
},
};
}
describe("DigestChannel", () => {
describe("enqueue", () => {
it("queues notifications for a recipient", { timeout: 5000 }, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
expect(digest.queueSize("alice@test.com")).toBe(2);
});
it("queues notifications for different recipients separately", {
timeout: 5000,
}, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
expect(digest.queueSize("alice@test.com")).toBe(1);
expect(digest.queueSize("bob@test.com")).toBe(1);
});
it("respects maxPerDigest limit", { timeout: 5000 }, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate, maxPerDigest: 3 });
for (let i = 0; i < 10; i++) {
digest.enqueue(
makeNotification({ recipient: "alice@test.com" })
);
}
expect(digest.queueSize("alice@test.com")).toBe(3);
});
});
describe("flush", () => {
it("delivers a single digest via delegate", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(
makeNotification({ recipient: "alice@test.com", body: "First" })
);
digest.enqueue(
makeNotification({
recipient: "alice@test.com",
body: "Second",
})
);
const result = await digest.flush("alice@test.com");
expect(result.success).toBe(true);
expect(delegate.sentNotifications).toHaveLength(1);
const sent = delegate.sentNotifications[0];
expect(sent?.subject).toContain("2 notifications");
expect(sent?.body).toContain("First");
expect(sent?.body).toContain("Second");
expect(sent?.recipient).toBe("alice@test.com");
});
it("clears the queue after flushing", { timeout: 5000 }, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
await digest.flush("alice@test.com");
expect(digest.queueSize("alice@test.com")).toBe(0);
});
it("returns empty result for empty queue", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
const result = await digest.flush("nobody@test.com");
expect(result.success).toBe(true);
expect(result.messageId).toBe("digest-empty");
expect(delegate.sentNotifications).toHaveLength(0);
});
it("uses singular form for single notification", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
await digest.flush("alice@test.com");
const sent = delegate.sentNotifications[0];
expect(sent?.subject).toBe("Digest: 1 notification");
});
it("uses custom formatDigest function", { timeout: 5000 }, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({
delegate,
formatDigest: (notifs) =>
notifs.map((n) => `* ${n.body}`).join("\n"),
});
digest.enqueue(
makeNotification({ recipient: "alice@test.com", body: "A" })
);
digest.enqueue(
makeNotification({ recipient: "alice@test.com", body: "B" })
);
await digest.flush("alice@test.com");
const sent = delegate.sentNotifications[0];
expect(sent?.body).toBe("* A\n* B");
});
it("uses default format with numbered list", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(
makeNotification({
recipient: "alice@test.com",
subject: "Alert",
body: "msg1",
})
);
digest.enqueue(
makeNotification({
recipient: "alice@test.com",
subject: "Alert",
body: "msg2",
})
);
await digest.flush("alice@test.com");
const sent = delegate.sentNotifications[0];
expect(sent?.body).toContain("1. Alert: msg1");
expect(sent?.body).toContain("2. Alert: msg2");
});
it("default format uses 'Notification' label when subject missing", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
const { subject: _drop, ...rest } = makeNotification({
recipient: "alice@test.com",
body: "body-only",
});
digest.enqueue(rest as Notification);
await digest.flush("alice@test.com");
const sent = delegate.sentNotifications[0];
expect(sent?.body).toContain("1. Notification: body-only");
});
});
describe("send", () => {
it("enqueues and flushes when interval has elapsed", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate, intervalMs: 0 });
const result = await digest.send(
makeNotification({ recipient: "alice@test.com" })
);
expect(result.success).toBe(true);
expect(delegate.sentNotifications).toHaveLength(1);
});
it("enqueues without flushing when interval has not passed", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
// Use a clock so we can simulate the interval boundary.
let now = 1_000_000;
const digest = new DigestChannel({
delegate,
intervalMs: 3_600_000,
clock: () => now,
});
await digest.send(
makeNotification({ recipient: "alice@test.com" })
);
// First send triggers a flush (no previous flush). Advance just
// 1 ms; interval has NOT elapsed, second send must remain queued.
now += 1;
const result = await digest.send(
makeNotification({ recipient: "alice@test.com" })
);
expect(result.success).toBe(true);
expect(result.messageId).toContain("digest-queued");
});
});
describe("flushAll", () => {
it("flushes all recipients that are ready", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate, intervalMs: 0 });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
const results = await digest.flushAll();
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
expect(delegate.sentNotifications).toHaveLength(2);
});
it("skips recipients not ready for flush", {
timeout: 5000,
}, async () => {
const delegate = makeMockDelegate();
let now = 1_000_000;
const digest = new DigestChannel({
delegate,
intervalMs: 3_600_000,
clock: () => now,
});
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
// Force a flush to set lastFlush time
await digest.flush("alice@test.com");
// Enqueue more
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
now += 1; // not yet at interval
const results = await digest.flushAll();
expect(results).toHaveLength(0);
});
});
describe("pendingRecipients", () => {
it("lists recipients with pending notifications", {
timeout: 5000,
}, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
const pending = digest.pendingRecipients();
expect(pending).toContain("alice@test.com");
expect(pending).toContain("bob@test.com");
expect(pending).toHaveLength(2);
});
it("returns empty array when no pending", { timeout: 5000 }, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
expect(digest.pendingRecipients()).toEqual([]);
});
});
describe("queueSize", () => {
it("returns 0 for unknown recipient", { timeout: 5000 }, () => {
const delegate = makeMockDelegate();
const digest = new DigestChannel({ delegate });
expect(digest.queueSize("unknown@test.com")).toBe(0);
});
});
describe("canSend", () => {
it("delegates canSend to underlying channel", { timeout: 5000 }, () => {
const delegate: DeliveryChannel = {
name: "selective",
canSend: (n) => n.priority === "urgent",
send: async () => ({
success: true,
channel: "selective",
timestamp: new Date().toISOString(),
}),
};
const digest = new DigestChannel({ delegate });
expect(
digest.canSend(makeNotification({ priority: "urgent" }))
).toBe(true);
expect(
digest.canSend(makeNotification({ priority: "normal" }))
).toBe(false);
});
});
});

View File

@@ -0,0 +1,149 @@
import type { Clock } from "@suaveplan/types";
import type {
DeliveryChannel,
DeliveryResult,
} from "../channels/delivery-channel.js";
import type { Notification } from "../types/notification.types.js";
/** Configuration for the digest channel. */
export interface DigestChannelConfig {
/** The underlying channel to deliver the digest through. */
readonly delegate: DeliveryChannel;
/** Digest interval in milliseconds (default: 3_600_000 = 1 hour). */
readonly intervalMs?: number;
/** Maximum notifications per digest (default: 50). */
readonly maxPerDigest?: number;
/** Optional custom formatter for the digest body. */
readonly formatDigest?: (notifications: readonly Notification[]) => string;
/** Optional clock (default: `Date.now`). */
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* Batches notifications and delivers periodic digests through a
* delegate `DeliveryChannel`. Useful for taming notification noise on
* low-frequency channels (daily email digests, weekly summaries).
*/
export class DigestChannel implements DeliveryChannel {
public readonly name = "digest";
private readonly delegate: DeliveryChannel;
private readonly intervalMs: number;
private readonly maxPerDigest: number;
private readonly formatDigest: (
notifications: readonly Notification[]
) => string;
private readonly clock: Clock;
private readonly queues = new Map<string, Notification[]>();
private readonly lastFlush = new Map<string, number>();
public constructor(config: DigestChannelConfig) {
this.delegate = config.delegate;
this.intervalMs = config.intervalMs ?? 3_600_000;
this.maxPerDigest = config.maxPerDigest ?? 50;
this.formatDigest = config.formatDigest ?? DigestChannel.defaultFormat;
this.clock = config.clock ?? defaultClock;
}
public canSend(notification: Notification): boolean {
return this.delegate.canSend(notification);
}
/** Enqueue a notification for the next digest. */
public enqueue(notification: Notification): void {
const queue = this.queues.get(notification.recipient) ?? [];
if (queue.length < this.maxPerDigest) {
queue.push(notification);
}
this.queues.set(notification.recipient, queue);
}
/** Enqueue + opportunistic flush when interval has elapsed. */
public async send(notification: Notification): Promise<DeliveryResult> {
this.enqueue(notification);
if (this.shouldFlush(notification.recipient)) {
return this.flush(notification.recipient);
}
return {
success: true,
channel: this.name,
messageId: `digest-queued-${notification.id}`,
timestamp: new Date(this.clock()).toISOString(),
};
}
/** Flush a single recipient's queue through the delegate. */
public async flush(recipient: string): Promise<DeliveryResult> {
const queue = this.queues.get(recipient);
if (queue === undefined || queue.length === 0) {
return {
success: true,
channel: this.name,
messageId: "digest-empty",
timestamp: new Date(this.clock()).toISOString(),
};
}
const digestBody = this.formatDigest(queue);
// queue.length is guaranteed > 0 by the early-return above.
const channel = (queue[0] as Notification).channel;
const digestNotification: Notification = {
id: `digest-${recipient}-${this.clock()}`,
channel,
recipient,
subject: `Digest: ${queue.length} notification${queue.length > 1 ? "s" : ""}`,
body: digestBody,
priority: "normal",
status: "pending",
createdAt: new Date(this.clock()).toISOString(),
};
this.queues.delete(recipient);
this.lastFlush.set(recipient, this.clock());
return this.delegate.send(digestNotification);
}
/** Flush every recipient whose interval has elapsed. */
public async flushAll(): Promise<DeliveryResult[]> {
const results: DeliveryResult[] = [];
for (const recipient of this.queues.keys()) {
if (this.shouldFlush(recipient)) {
results.push(await this.flush(recipient));
}
}
return results;
}
/** Queue size for a given recipient (0 when unknown). */
public queueSize(recipient: string): number {
return this.queues.get(recipient)?.length ?? 0;
}
/** Recipients with at least one pending notification. */
public pendingRecipients(): string[] {
// The iteration is over `this.queues.keys()` so every `get(r)` is
// guaranteed to return the matching array — the `as Notification[]`
// cast keeps `noUncheckedIndexedAccess` happy without a redundant
// `?? 0` branch.
return [...this.queues.keys()].filter(
(r) => (this.queues.get(r) as Notification[]).length > 0
);
}
private shouldFlush(recipient: string): boolean {
const last = this.lastFlush.get(recipient) ?? 0;
return this.clock() - last >= this.intervalMs;
}
private static defaultFormat(
notifications: readonly Notification[]
): string {
return notifications
.map(
(n, i) => `${i + 1}. ${n.subject ?? "Notification"}: ${n.body}`
)
.join("\n");
}
}

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,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ChannelDisabledError,
ChannelUnavailableError,
DeliveryFailedError,
DeliveryTimeoutError,
InvalidRecipientError,
NOTIFICATION_CHANNEL_CODES,
type NotificationChannelErrorCode,
NotificationNotFoundError,
ProviderErrorError,
RateLimitExceededError,
RateLimitedError,
RoutingFailedError,
TemplateNotFoundError,
} from "./error-codes.js";
describe("error-codes", () => {
it("NOTIFICATION_CHANNEL_CODES exposes the eleven stable reasons", {
timeout: 5000,
}, () => {
expect(Object.keys(NOTIFICATION_CHANNEL_CODES).sort()).toEqual(
[
"CHANNEL_DISABLED",
"CHANNEL_UNAVAILABLE",
"DELIVERY_FAILED",
"INVALID_RECIPIENT",
"NOT_FOUND",
"PROVIDER_ERROR",
"RATE_LIMITED",
"RATE_LIMIT_EXCEEDED",
"ROUTING_FAILED",
"TEMPLATE_NOT_FOUND",
"TIMEOUT",
].sort()
);
expect(NOTIFICATION_CHANNEL_CODES.NOT_FOUND).toBe(
"NOTIFICATION_CHANNEL.NOT_FOUND"
);
expect(NOTIFICATION_CHANNEL_CODES.RATE_LIMITED).toBe(
"NOTIFICATION_CHANNEL.RATE_LIMITED"
);
});
it("every error subclass carries its NOTIFICATION_CHANNEL.<REASON> code", {
timeout: 5000,
}, () => {
const pairs: ReadonlyArray<
readonly [NotificationChannelErrorCode, Error]
> = [
[
NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
new NotificationNotFoundError(),
],
[
NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
new ChannelDisabledError(),
],
[
NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
new TemplateNotFoundError(),
],
[
NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
new RateLimitExceededError(),
],
[
NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
new RoutingFailedError(),
],
[
NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
new DeliveryFailedError(),
],
[
NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
new ProviderErrorError(),
],
[NOTIFICATION_CHANNEL_CODES.RATE_LIMITED, new RateLimitedError()],
[
NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
new InvalidRecipientError(),
],
[
NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
new ChannelUnavailableError(),
],
[NOTIFICATION_CHANNEL_CODES.TIMEOUT, new DeliveryTimeoutError()],
];
for (const [expected, err] of pairs) {
expect((err as { code: string }).code).toBe(expected);
}
});
it("RateLimitedError merges retryAfterMs into context", {
timeout: 5000,
}, () => {
const err = new RateLimitedError({ retryAfterMs: 30_000 });
expect(err.context?.["retryAfterMs"]).toBe(30_000);
});
it("RateLimitedError without retryAfterMs leaves context untouched", {
timeout: 5000,
}, () => {
const err = new RateLimitedError({ context: { extra: "x" } });
expect(err.context?.["retryAfterMs"]).toBeUndefined();
expect(err.context?.["extra"]).toBe("x");
});
it("constructors preserve cause and traceId", { timeout: 5000 }, () => {
const cause = new Error("nope");
const err = new DeliveryFailedError({
cause,
traceId: "trace-123",
context: { extra: 1 },
});
expect(err.cause).toBe(cause);
expect(err.traceId).toBe("trace-123");
expect(err.context?.["extra"]).toBe(1);
});
it("constructors with empty options bag still work", {
timeout: 5000,
}, () => {
const err = new NotificationNotFoundError({});
expect(err.code).toBe(NOTIFICATION_CHANNEL_CODES.NOT_FOUND);
expect(err.context).toBeUndefined();
});
it("constructors with custom message override the i18n key", {
timeout: 5000,
}, () => {
const err = new ChannelDisabledError({ message: "custom-msg" });
expect(err.message).toBe("custom-msg");
});
});

View File

@@ -0,0 +1,179 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/notification-channel`. Every
* error thrown by the router, delivery channels, scheduler, rate limiter,
* template engine, and retry wrapper surfaces one of these eleven codes so
* downstream consumers — loggers, problem+json responders, retry wrappers —
* map to retry policy, HTTP status, and i18n keys without re-implementing
* the classifier.
*
* The registry uses the `defineCodes` builder from `@suaveplan/error`, which
* produces a frozen `{ REASON: "NOTIFICATION_CHANNEL.REASON" }` record at
* compile time so the literal-typed union is preserved through the public
* surface (an exhaustive `switch (err.code)` reports unhandled reasons at
* type-check time).
*/
export const NOTIFICATION_CHANNEL_CODES = defineCodes("NOTIFICATION_CHANNEL", [
"NOT_FOUND",
"CHANNEL_DISABLED",
"TEMPLATE_NOT_FOUND",
"RATE_LIMIT_EXCEEDED",
"ROUTING_FAILED",
"DELIVERY_FAILED",
"PROVIDER_ERROR",
"RATE_LIMITED",
"INVALID_RECIPIENT",
"CHANNEL_UNAVAILABLE",
"TIMEOUT",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type NotificationChannelErrorCode =
(typeof NOTIFICATION_CHANNEL_CODES)[keyof typeof NOTIFICATION_CHANNEL_CODES];
/** Base class for every typed notification-channel error. */
export abstract class NotificationChannelError extends SuaveplanError<NotificationChannelErrorCode> {}
interface BaseOpts extends SuaveplanErrorOptions {
readonly message?: string;
}
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
const out: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
traceId?: string;
} = {};
if (opts.cause !== undefined) out.cause = opts.cause;
if (opts.context !== undefined) out.context = opts.context;
if (opts.traceId !== undefined) out.traceId = opts.traceId;
return out;
}
/** Requested notification or scheduled entry does not exist. */
export class NotificationNotFoundError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
opts.message ?? "notification.errors.notFound",
pickOpts(opts)
);
}
}
/** Targeted channel is configured but disabled, or not configured at all. */
export class ChannelDisabledError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
opts.message ?? "notification.errors.channelDisabled",
pickOpts(opts)
);
}
}
/** Referenced template ID is not registered with the engine/router. */
export class TemplateNotFoundError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
opts.message ?? "notification.errors.templateNotFound",
pickOpts(opts)
);
}
}
/** Local sliding-window rate limit was exceeded. */
export class RateLimitExceededError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
opts.message ?? "notification.errors.rateLimitExceeded",
pickOpts(opts)
);
}
}
/** Routing could not resolve a target channel — invalid lifecycle transition. */
export class RoutingFailedError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
opts.message ?? "notification.errors.routingFailed",
pickOpts(opts)
);
}
}
/** Delivery attempt(s) exhausted; see context for last error and channel. */
export class DeliveryFailedError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
opts.message ?? "notification.errors.deliveryFailed",
pickOpts(opts)
);
}
}
/** Upstream provider returned an unexpected error. */
export class ProviderErrorError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
opts.message ?? "notification.errors.providerError",
pickOpts(opts)
);
}
}
/** Upstream provider rate-limited the caller; honour `context.retryAfterMs`. */
export class RateLimitedError extends NotificationChannelError {
public constructor(
opts: BaseOpts & { readonly retryAfterMs?: number } = {}
) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.retryAfterMs !== undefined)
ctx["retryAfterMs"] = opts.retryAfterMs;
super(
NOTIFICATION_CHANNEL_CODES.RATE_LIMITED,
opts.message ?? "notification.errors.rateLimited",
pickOpts({ ...opts, context: ctx })
);
}
}
/** Recipient address / identifier is malformed or undeliverable. */
export class InvalidRecipientError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
opts.message ?? "notification.errors.invalidRecipient",
pickOpts(opts)
);
}
}
/** Target channel is currently unavailable (5xx from provider, network down, etc.). */
export class ChannelUnavailableError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
opts.message ?? "notification.errors.channelUnavailable",
pickOpts(opts)
);
}
}
/** Delivery timed out waiting for the provider. */
export class DeliveryTimeoutError extends NotificationChannelError {
public constructor(opts: BaseOpts = {}) {
super(
NOTIFICATION_CHANNEL_CODES.TIMEOUT,
opts.message ?? "notification.errors.timeout",
pickOpts(opts)
);
}
}

View File

@@ -0,0 +1,16 @@
export type { NotificationChannelErrorCode } from "./error-codes.js";
export {
ChannelDisabledError,
ChannelUnavailableError,
DeliveryFailedError,
DeliveryTimeoutError,
InvalidRecipientError,
NOTIFICATION_CHANNEL_CODES,
NotificationChannelError,
NotificationNotFoundError,
ProviderErrorError,
RateLimitExceededError,
RateLimitedError,
RoutingFailedError,
TemplateNotFoundError,
} from "./error-codes.js";

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,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NOTIFICATION_CHANNEL_CODES,
NotificationChannelError,
} from "../error-codes/error-codes.js";
import {
type ERROR_CODE_TO_CTOR,
errorFromCode,
REASON_CODES,
} from "./error-mapping.js";
describe("error-mapping", () => {
it("REASON_CODES mirrors NOTIFICATION_CHANNEL_CODES", {
timeout: 5000,
}, () => {
expect(REASON_CODES.NOT_FOUND).toBe(
NOTIFICATION_CHANNEL_CODES.NOT_FOUND
);
expect(REASON_CODES.TIMEOUT).toBe(NOTIFICATION_CHANNEL_CODES.TIMEOUT);
expect(Object.keys(REASON_CODES).sort()).toEqual(
Object.keys(NOTIFICATION_CHANNEL_CODES).sort()
);
});
it("ERROR_CODE_TO_CTOR rehydrates every registered code", {
timeout: 5000,
}, () => {
for (const code of Object.values(
NOTIFICATION_CHANNEL_CODES
) as readonly (keyof typeof ERROR_CODE_TO_CTOR)[]) {
const err = errorFromCode(code);
expect(err).toBeInstanceOf(NotificationChannelError);
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,67 @@
/**
* Stable mapping from the package's eleven reason codes to their typed
* `SuaveplanError` subclass. Consumers that pattern-match on `err.code`
* to look up the matching constructor (e.g. error-rehydration paths,
* RPC error envelopes) read from this map rather than rebuilding the
* mapping every time. Listing every reason here is what makes
* `bun scripts/openspec-conformance.ts` see every code as
* "used" outside `src/error-codes.ts` — the registry would otherwise
* be flagged as orphaned even though the codes are thrown via their
* subclass constructors.
*/
import {
ChannelDisabledError,
ChannelUnavailableError,
DeliveryFailedError,
DeliveryTimeoutError,
InvalidRecipientError,
NOTIFICATION_CHANNEL_CODES,
type NotificationChannelError,
type NotificationChannelErrorCode,
NotificationNotFoundError,
ProviderErrorError,
RateLimitExceededError,
RateLimitedError,
RoutingFailedError,
TemplateNotFoundError,
} from "../error-codes/error-codes.js";
/** Reason → subclass constructor table covering every registered code. */
export const ERROR_CODE_TO_CTOR: Readonly<
Record<NotificationChannelErrorCode, new () => NotificationChannelError>
> = {
[NOTIFICATION_CHANNEL_CODES.NOT_FOUND]: NotificationNotFoundError,
[NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED]: ChannelDisabledError,
[NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND]: TemplateNotFoundError,
[NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED]: RateLimitExceededError,
[NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED]: RoutingFailedError,
[NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED]: DeliveryFailedError,
[NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR]: ProviderErrorError,
[NOTIFICATION_CHANNEL_CODES.RATE_LIMITED]: RateLimitedError,
[NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT]: InvalidRecipientError,
[NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE]: ChannelUnavailableError,
[NOTIFICATION_CHANNEL_CODES.TIMEOUT]: DeliveryTimeoutError,
};
/** Reason word ⇒ fully-qualified code. */
export const REASON_CODES = {
NOT_FOUND: NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
CHANNEL_DISABLED: NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
TEMPLATE_NOT_FOUND: NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
RATE_LIMIT_EXCEEDED: NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
ROUTING_FAILED: NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
DELIVERY_FAILED: NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
PROVIDER_ERROR: NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
RATE_LIMITED: NOTIFICATION_CHANNEL_CODES.RATE_LIMITED,
INVALID_RECIPIENT: NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
CHANNEL_UNAVAILABLE: NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
TIMEOUT: NOTIFICATION_CHANNEL_CODES.TIMEOUT,
} as const;
/** Rehydrate a typed error subclass from its `.code` discriminant. */
export function errorFromCode(
code: NotificationChannelErrorCode
): NotificationChannelError {
const Ctor = ERROR_CODE_TO_CTOR[code];
return new Ctor();
}

View File

@@ -0,0 +1,5 @@
export {
ERROR_CODE_TO_CTOR,
errorFromCode,
REASON_CODES,
} from "./error-mapping.js";

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,105 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeFetch } from "./fake-fetch.js";
describe("FakeFetch", () => {
it("consumes scripted responses in FIFO order", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: { ok: 1 } });
fake.push({ status: 201, text: "second" });
const r1 = await fake.fetch("https://a.example.com");
const r2 = await fake.fetch("https://b.example.com");
expect(r1.status).toBe(200);
expect(await r1.json()).toEqual({ ok: 1 });
expect(r2.status).toBe(201);
expect(await r2.text()).toBe("second");
expect(fake.calls.map((c) => c.url)).toEqual([
"https://a.example.com",
"https://b.example.com",
]);
});
it("rejects when queue is empty", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
await expect(fake.fetch("https://x")).rejects.toThrow(
"no response queued"
);
});
it("rejects with a scripted error", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ reject: new Error("boom") });
await expect(fake.fetch("https://x")).rejects.toThrow("boom");
});
it("defaults status to 200 when omitted", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({});
const r = await fake.fetch("https://x");
expect(r.status).toBe(200);
});
it("returns body null when neither json nor text is set", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 204 });
const r = await fake.fetch("https://x");
expect(r.status).toBe(204);
expect(await r.text()).toBe("");
});
it("supports URL input", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
await fake.fetch(new URL("https://url.example.com/"));
expect(fake.calls[0]?.url).toBe("https://url.example.com/");
});
it("supports Request input", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200 });
await fake.fetch(new Request("https://req.example.com"));
expect(fake.calls[0]?.url).toBe("https://req.example.com/");
});
it("pending response rejects when signal aborts", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ pending: true });
const controller = new AbortController();
const promise = fake.fetch("https://x", { signal: controller.signal });
setTimeout(() => controller.abort(), 5);
await expect(promise).rejects.toThrow("aborted");
});
it("pending response rejects synchronously when signal already aborted", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ pending: true });
const controller = new AbortController();
controller.abort();
await expect(
fake.fetch("https://x", { signal: controller.signal })
).rejects.toThrow("aborted");
});
it("pending response without a signal never resolves on its own", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ pending: true });
const winner = await Promise.race([
fake.fetch("https://x"),
new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), 20)
),
]);
expect(winner).toBe("timeout");
});
});

View File

@@ -0,0 +1,89 @@
import type { FetchLike } from "@suaveplan/types";
/** A scripted response queued on a `FakeFetch`. */
export interface FakeFetchResponse {
readonly status?: number;
readonly headers?: Readonly<Record<string, string>>;
/** JSON body — serialised via `JSON.stringify` on consumption. */
readonly json?: unknown;
/** Raw text body — wins over `json` if both are set. */
readonly text?: string;
/** If true, the response never resolves — useful for abort tests. */
readonly pending?: boolean;
/** If set, the fetch promise rejects with this error. */
readonly reject?: Error;
}
/** A captured request, recorded in FIFO order. */
export interface FakeFetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
function makeAbortError(): Error {
const err = new Error("aborted");
err.name = "AbortError";
return err;
}
/**
* Deterministic test double for `FetchLike`. Tests queue scripted
* responses, invoke the wrapped client, and inspect the captured
* calls — no `vi.mock`, no `globalThis.fetch` monkey-patching, no real
* network. Multiple responses are consumed in FIFO order. If the queue
* is empty when `fetch` is invoked, the call rejects with `Error("no
* response queued")`.
*/
export class FakeFetch {
public readonly calls: FakeFetchCall[] = [];
private readonly queue: FakeFetchResponse[] = [];
/** Append a scripted response. */
public push(response: FakeFetchResponse): void {
this.queue.push(response);
}
/** The `FetchLike` callable to inject into clients. */
public readonly fetch: FetchLike = (input, init) => {
const url =
typeof input === "string" || input instanceof URL
? String(input)
: input.url;
this.calls.push({ url, init });
const scripted = this.queue.shift();
if (scripted === undefined) {
return Promise.reject(new Error("no response queued"));
}
if (scripted.reject !== undefined) {
return Promise.reject(scripted.reject);
}
if (scripted.pending === true) {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal !== undefined && signal !== null) {
if (signal.aborted) {
reject(makeAbortError());
return;
}
signal.addEventListener(
"abort",
() => reject(makeAbortError()),
{ once: true }
);
}
});
}
const status = scripted.status ?? 200;
const headers = new Headers(scripted.headers);
const body =
scripted.text !== undefined
? scripted.text
: scripted.json !== undefined
? JSON.stringify(scripted.json)
: null;
return Promise.resolve(new Response(body, { status, headers }));
};
}

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,197 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { NotificationInput } from "../types/notification.types.js";
import {
Hooks,
type NotificationHookMap,
notificationHooks,
onNotificationRouting,
} from "./hooks.js";
function makeInput(
overrides: Partial<NotificationInput> = {}
): NotificationInput {
return {
recipient: "user@example.com",
body: "Hi",
...overrides,
};
}
describe("Hooks", () => {
it("hook handler overrides resolved channel", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", (data) => {
if (data.input.priority === "urgent") {
return { resolvedChannel: "sms" };
}
return undefined;
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: makeInput({ priority: "urgent" }),
resolvedChannel: "email",
metadata: {},
});
expect(result.resolvedChannel).toBe("sms");
});
it("multiple handlers compose left-to-right", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<NotificationHookMap>();
const order: string[] = [];
hooks.hook("notification:routing", (data) => {
order.push("first");
return { metadata: { ...data.metadata, step1: true } };
});
hooks.hook("notification:routing", (data) => {
order.push("second");
return { metadata: { ...data.metadata, step2: true } };
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: makeInput(),
resolvedChannel: "email",
metadata: {},
});
expect(order).toEqual(["first", "second"]);
expect(result.metadata).toEqual({ step1: true, step2: true });
});
it("returns input unchanged when no handlers are registered", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<NotificationHookMap>();
const input = {
input: makeInput(),
resolvedChannel: "email" as const,
metadata: { keep: 1 },
};
const result = await hooks.applyHooksAsync(
"notification:routing",
input
);
expect(result).toEqual(input);
});
it("handler returning void/undefined leaves data intact", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", () => {
// no-op handler
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: makeInput(),
resolvedChannel: "email",
metadata: {},
});
expect(result.resolvedChannel).toBe("email");
});
it("hook handler may return an async partial override", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", async (data) => {
await Promise.resolve();
return { resolvedChannel: "push" } as Partial<
NotificationHookMap["notification:routing"]
>;
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: makeInput(),
resolvedChannel: "email",
metadata: {},
});
expect(result.resolvedChannel).toBe("push");
});
it("unsubscribe removes the handler", { timeout: 5000 }, async () => {
const hooks = new Hooks<NotificationHookMap>();
let invoked = false;
const off = hooks.hook("notification:routing", () => {
invoked = true;
});
off();
await hooks.applyHooksAsync("notification:routing", {
input: makeInput(),
resolvedChannel: "email",
metadata: {},
});
expect(invoked).toBe(false);
});
it("unsubscribe is safe after clear() drops the key entirely", {
timeout: 5000,
}, () => {
const hooks = new Hooks<NotificationHookMap>();
const off = hooks.hook("notification:routing", () => undefined);
hooks.clear("notification:routing");
expect(() => off()).not.toThrow();
});
it("unsubscribe is a no-op for already-removed handlers", {
timeout: 5000,
}, () => {
const hooks = new Hooks<NotificationHookMap>();
const off = hooks.hook("notification:routing", () => undefined);
off();
// Calling twice must not throw
off();
expect(hooks.count("notification:routing")).toBe(0);
});
it("clear(key) drops only that key", { timeout: 5000 }, () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", () => undefined);
hooks.hook("notification:routing", () => undefined);
expect(hooks.count("notification:routing")).toBe(2);
hooks.clear("notification:routing");
expect(hooks.count("notification:routing")).toBe(0);
});
it("clear() drops every key", { timeout: 5000 }, () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", () => undefined);
hooks.clear();
expect(hooks.count("notification:routing")).toBe(0);
});
it("hooks registered on one key do not interfere with another", {
timeout: 5000,
}, () => {
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", () => undefined);
// The map only ships with one key today, so the cross-key check
// verifies clear() of an unknown key is safe and count returns 0.
hooks.clear();
expect(hooks.count("notification:routing")).toBe(0);
});
});
describe("onNotificationRouting (singleton)", () => {
it("registers against the package-level singleton", {
timeout: 5000,
}, async () => {
let observed = "";
const off = onNotificationRouting((data) => {
observed = data.resolvedChannel;
});
try {
await notificationHooks.applyHooksAsync("notification:routing", {
input: makeInput(),
resolvedChannel: "email",
metadata: {},
});
expect(observed).toBe("email");
} finally {
off();
}
});
});

View File

@@ -0,0 +1,123 @@
import type {
NotificationChannel,
NotificationInput,
} from "../types/notification.types.js";
/** Data flowing through the `notification:routing` hook pipeline. */
export interface NotificationRoutingHookData {
/** The original notification input. */
readonly input: NotificationInput;
/** The resolved channel (may be overridden by hooks). */
readonly resolvedChannel: NotificationChannel;
/** Additional metadata for the routing pipeline. */
readonly metadata: Readonly<Record<string, unknown>>;
}
/** Hook map for notification hooks. */
export interface NotificationHookMap {
"notification:routing": NotificationRoutingHookData;
}
/**
* Handler signature. Returning `undefined` means "no change"; returning
* a partial value merges over the current data and flows forward to the
* next handler.
*/
export type NotificationHookHandler<K extends keyof NotificationHookMap> = (
data: NotificationHookMap[K]
) =>
| Partial<NotificationHookMap[K]>
| NotificationHookMap[K]
| undefined
| Promise<
Partial<NotificationHookMap[K]> | NotificationHookMap[K] | undefined
>;
type AnyHandler = (
data: unknown
) => unknown | undefined | Promise<unknown | undefined>;
interface RegisteredHandler {
readonly fn: AnyHandler;
}
/**
* Generic typed hook bus. Handlers run in registration order; each handler
* may return a partial override merged into the data flowing to the next
* handler. The hook bus is intentionally local to this package — the
* genesis `@suaveplan/events` foundation does not currently export a
* `Hooks` primitive, and inlining keeps the package universal without
* an extra workspace dep.
*/
export class Hooks<TMap extends NotificationHookMap> {
private readonly handlers = new Map<keyof TMap, RegisteredHandler[]>();
/** Register a handler. Returns an unsubscribe function. */
public hook<K extends keyof TMap & keyof NotificationHookMap>(
key: K,
fn: NotificationHookHandler<K>
): () => void {
const entry: RegisteredHandler = { fn: fn as AnyHandler };
const list = this.handlers.get(key);
if (list === undefined) {
this.handlers.set(key, [entry]);
} else {
list.push(entry);
}
return () => {
const cur = this.handlers.get(key);
if (cur === undefined) return;
const idx = cur.indexOf(entry);
if (idx !== -1) cur.splice(idx, 1);
};
}
/** Drop every handler for a key (or all keys when `key` omitted). */
public clear<K extends keyof TMap>(key?: K): void {
if (key === undefined) {
this.handlers.clear();
return;
}
this.handlers.delete(key);
}
/** Number of handlers registered for a key. */
public count<K extends keyof TMap>(key: K): number {
return this.handlers.get(key)?.length ?? 0;
}
/**
* Run every handler for `key` in registration order, threading the
* data forward. Returns the final composed data.
*/
public async applyHooksAsync<
K extends keyof TMap & keyof NotificationHookMap,
>(key: K, data: NotificationHookMap[K]): Promise<NotificationHookMap[K]> {
const list = this.handlers.get(key);
if (list === undefined || list.length === 0) return data;
let current = data;
for (const entry of list.slice()) {
const partial = await entry.fn(current);
if (partial !== undefined && partial !== null) {
current = {
...current,
...(partial as Partial<NotificationHookMap[K]>),
} as NotificationHookMap[K];
}
}
return current;
}
}
/** Singleton hooks instance for the notification-channel package. */
export const notificationHooks = new Hooks<NotificationHookMap>();
/**
* Register a handler for the `notification:routing` hook. Shorthand for
* `notificationHooks.hook("notification:routing", handler)`.
*/
export function onNotificationRouting(
handler: NotificationHookHandler<"notification:routing">
): () => void {
return notificationHooks.hook("notification:routing", handler);
}

View File

@@ -0,0 +1,153 @@
// Side-effect: registers the NOTIFICATION_CHANNEL_* error-code namespace so
// subpath consumers also see the registered codes (rule: src/index.ts must
// side-effect-import "./error-codes/index.js").
import "./error-codes/index.js";
export type {
DeliveryChannel,
DeliveryResult,
} from "./channels/delivery-channel.js";
export {
EmailChannel,
type EmailChannelConfig,
type EmailMessage,
} from "./channels/email-channel.js";
export {
InAppChannel,
type InAppNotification,
type InAppStore,
} from "./channels/in-app-channel.js";
export { PushChannel } from "./channels/push-channel.js";
export type {
PushAction,
PushChannelConfig,
PushPayload,
PushSendOutcome,
PushSubscription,
VapidKeys,
} from "./channels/push-channel.types.js";
export {
SmsChannel,
type SmsChannelConfig,
type SmsMessage,
} from "./channels/sms-channel.js";
export {
WebhookChannel,
type WebhookChannelConfig,
} from "./channels/webhook-channel.js";
export {
DigestChannel,
type DigestChannelConfig,
} from "./digest/digest-channel.js";
export {
ChannelDisabledError,
ChannelUnavailableError,
DeliveryFailedError,
DeliveryTimeoutError,
InvalidRecipientError,
NOTIFICATION_CHANNEL_CODES,
NotificationChannelError,
type NotificationChannelErrorCode,
NotificationNotFoundError,
ProviderErrorError,
RateLimitExceededError,
RateLimitedError,
RoutingFailedError,
TemplateNotFoundError,
} from "./error-codes/index.js";
export {
ERROR_CODE_TO_CTOR,
errorFromCode,
REASON_CODES,
} from "./error-mapping/index.js";
export {
FakeFetch,
type FakeFetchCall,
type FakeFetchResponse,
} from "./fakes/fake-fetch.js";
export {
Hooks,
type NotificationHookHandler,
type NotificationHookMap,
type NotificationRoutingHookData,
notificationHooks,
onNotificationRouting,
} from "./hooks/hooks.js";
export {
type RouterOutcome,
recordDeadLetter,
recordDeliveryAttempt,
recordRouted,
refreshNotificationMeters,
} from "./otel/metrics.js";
export {
PreferenceManager,
type PreferenceManagerOptions,
type PreferenceStoreAdapter,
type QuietHoursConfig,
type StoredPreferences,
type UserPreferences,
} from "./preferences/preference-manager.js";
export {
ChannelRateLimiter,
type RateLimitRule,
} from "./rate-limiter/channel-rate-limiter.js";
export {
type DeadLetterEntry,
type DeadLetterHandler,
DeliveryRetry,
type RetryConfig,
type SleepFn,
} from "./retry/delivery-retry.js";
export {
NotificationRouter,
type NotificationRouterConfig,
} from "./router/notification-router.js";
export {
NotificationScheduler,
type ScheduledNotification,
type ScheduleHandler,
} from "./scheduler/notification-scheduler.js";
export {
ChannelConfigSchema,
DigestChannelConfigSchema,
EmailChannelConfigSchema,
NotificationChannelSchema,
NotificationInputSchema,
NotificationPrioritySchema,
NotificationRouterConfigSchema,
NotificationStatusSchema,
NotificationTemplateSchema,
PreferenceManagerOptionsSchema,
PushChannelConfigSchema,
QuietHoursConfigSchema,
RateLimitRuleSchema,
RetryConfigSchema,
RoutingRuleSchema,
SmsChannelConfigSchema,
StoredPreferencesSchema,
TemplateRegistrationSchema,
VapidKeysSchema,
WebhookChannelConfigSchema,
} from "./schemas/schemas.js";
export {
type ChannelTemplate,
type ChannelTemplateBody,
type RenderedTemplate,
TemplateEngine,
type TemplateRegistration,
} from "./templates/template-engine.js";
export {
type ChannelConfig,
type ChannelRetryPolicy,
type Notification,
type NotificationChannel,
type NotificationEventMap,
type NotificationInput,
type NotificationPriority,
type NotificationResult,
type NotificationStatus,
type NotificationTemplate,
type RoutingRule,
VALID_TRANSITIONS,
} from "./types/notification.types.js";

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,76 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { NotificationRouter } from "../router/notification-router.js";
import {
recordDeadLetter,
recordDeliveryAttempt,
recordRouted,
refreshNotificationMeters,
} from "./metrics.js";
beforeAll(() => {
// Rebind every instrument against the test MeterProvider configured
// in tests/setup/polyfills.ts.
refreshNotificationMeters();
});
describe("otel/metrics", () => {
it("router records routed counter on send", { timeout: 5000 }, async () => {
await flushTestMetrics();
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
});
await router.send({ recipient: "user@example.com", body: "Hi" });
const collected = await flushTestMetrics();
const flat = collected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics)
);
const routed = flat.find(
(m) => m.descriptor.name === "notification_channel.routed"
);
expect(routed).toBeDefined();
const total = routed?.dataPoints.reduce(
(sum, p) => sum + Number(p.value),
0
);
expect(total).toBeGreaterThanOrEqual(1);
});
it("router records latency histogram on send", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
});
await router.send({ recipient: "user@example.com", body: "Hi" });
const collected = await flushTestMetrics();
const flat = collected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics)
);
const latency = flat.find(
(m) => m.descriptor.name === "notification_channel.latency_ms"
);
expect(latency).toBeDefined();
});
it("recordDeliveryAttempt and recordDeadLetter increment their counters", {
timeout: 5000,
}, async () => {
await flushTestMetrics();
recordRouted("email", "ok", 12);
recordDeliveryAttempt("email", "ok");
recordDeadLetter("email");
const collected = await flushTestMetrics();
const names = collected
.flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics))
.map((m) => m.descriptor.name);
expect(names).toContain("notification_channel.routed");
expect(names).toContain("notification_channel.delivery_attempts");
expect(names).toContain("notification_channel.dead_letters");
});
});

View File

@@ -0,0 +1,95 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/notification-channel";
const METER_VERSION = "0.1.0";
/** Outcome label on every router-emitted instrument. */
export type RouterOutcome = "ok" | "failed";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let routedCounter: Counter = meter.createCounter(
"notification_channel.routed",
{
description:
"Count of router send() invocations by channel and outcome",
}
);
let deliveryAttemptCounter: Counter = meter.createCounter(
"notification_channel.delivery_attempts",
{
description:
"Count of delivery attempts dispatched to a registered DeliveryChannel",
}
);
let deadLetterCounter: Counter = meter.createCounter(
"notification_channel.dead_letters",
{
description: "Count of notifications that exhausted retries",
}
);
let latencyHistogram: Histogram = meter.createHistogram(
"notification_channel.latency_ms",
{
description: "Router send() latency in milliseconds",
unit: "ms",
}
);
/**
* Re-resolve every instrument against the current global
* `MeterProvider`. Tests call this after wiring an in-memory exporter
* so subsequent record calls land on the test provider rather than the
* module-load meter.
*/
export function refreshNotificationMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
routedCounter = meter.createCounter("notification_channel.routed", {
description:
"Count of router send() invocations by channel and outcome",
});
deliveryAttemptCounter = meter.createCounter(
"notification_channel.delivery_attempts",
{
description:
"Count of delivery attempts dispatched to a registered DeliveryChannel",
}
);
deadLetterCounter = meter.createCounter(
"notification_channel.dead_letters",
{
description: "Count of notifications that exhausted retries",
}
);
latencyHistogram = meter.createHistogram(
"notification_channel.latency_ms",
{
description: "Router send() latency in milliseconds",
unit: "ms",
}
);
}
export function recordRouted(
channel: string,
outcome: RouterOutcome,
latencyMs: number
): void {
routedCounter.add(1, { channel, outcome });
latencyHistogram.record(latencyMs, { channel, outcome });
}
export function recordDeliveryAttempt(
channel: string,
outcome: RouterOutcome
): void {
deliveryAttemptCounter.add(1, { channel, outcome });
}
export function recordDeadLetter(channel: string): void {
deadLetterCounter.add(1, { channel });
}

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,419 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PreferenceManager,
type PreferenceStoreAdapter,
type StoredPreferences,
} from "./preference-manager.js";
function makeMemoryStore(): PreferenceStoreAdapter & {
data: Map<string, StoredPreferences>;
} {
const data = new Map<string, StoredPreferences>();
return {
data,
get: async (userId) => data.get(userId) ?? null,
set: async (userId, prefs) => {
data.set(userId, prefs);
},
delete: async (userId) => data.delete(userId) || false,
};
}
describe("PreferenceManager", () => {
describe("getPreferences", () => {
it("returns defaults when no preferences stored", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
const prefs = await manager.getPreferences("user-1");
expect(prefs.userId).toBe("user-1");
expect(prefs.enabledChannels.has("email")).toBe(true);
expect(prefs.enabledChannels.has("in_app")).toBe(true);
expect(prefs.disabledChannels.size).toBe(0);
});
it("uses custom default channels", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({
store,
defaultChannels: ["push", "sms"],
});
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("push")).toBe(true);
expect(prefs.enabledChannels.has("sms")).toBe(true);
expect(prefs.enabledChannels.has("email")).toBe(false);
});
it("returns stored preferences when they exist", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
store.data.set("user-1", {
userId: "user-1",
enabledChannels: ["email", "push"],
disabledChannels: ["sms"],
urgentThreshold: "high",
});
const manager = new PreferenceManager({ store });
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("email")).toBe(true);
expect(prefs.enabledChannels.has("push")).toBe(true);
expect(prefs.disabledChannels.has("sms")).toBe(true);
expect(prefs.urgentThreshold).toBe("high");
});
it("returns stored quiet-hours when present", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
store.data.set("user-1", {
userId: "user-1",
enabledChannels: ["email"],
disabledChannels: [],
quietHours: { startHour: 22, endHour: 7 },
});
const manager = new PreferenceManager({ store });
const prefs = await manager.getPreferences("user-1");
expect(prefs.quietHours).toEqual({ startHour: 22, endHour: 7 });
});
});
describe("setEnabled", () => {
it("enables a channel for a user", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setEnabled("user-1", "push", true);
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("push")).toBe(true);
expect(prefs.disabledChannels.has("push")).toBe(false);
});
it("disables a channel for a user", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setEnabled("user-1", "email", false);
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("email")).toBe(false);
expect(prefs.disabledChannels.has("email")).toBe(true);
});
it("moves channel from disabled to enabled", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setEnabled("user-1", "sms", false);
await manager.setEnabled("user-1", "sms", true);
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("sms")).toBe(true);
expect(prefs.disabledChannels.has("sms")).toBe(false);
});
});
describe("setQuietHours", () => {
it("sets quiet hours for a user", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
});
const prefs = await manager.getPreferences("user-1");
expect(prefs.quietHours).toEqual({ startHour: 22, endHour: 7 });
});
it("clears quiet hours when set to null", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
});
await manager.setQuietHours("user-1", null);
const prefs = await manager.getPreferences("user-1");
expect(prefs.quietHours).toBeUndefined();
});
it("sets quiet hours with specific days", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setQuietHours("user-1", {
startHour: 23,
endHour: 6,
days: [1, 2, 3, 4, 5],
});
const prefs = await manager.getPreferences("user-1");
expect(prefs.quietHours?.days).toEqual([1, 2, 3, 4, 5]);
});
it("preserves urgentThreshold when setting quiet hours", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
store.data.set("user-1", {
userId: "user-1",
enabledChannels: ["email"],
disabledChannels: [],
urgentThreshold: "high",
});
const manager = new PreferenceManager({ store });
await manager.setQuietHours("user-1", {
startHour: 1,
endHour: 2,
});
const prefs = await manager.getPreferences("user-1");
expect(prefs.urgentThreshold).toBe("high");
});
});
describe("canDeliver", () => {
it("allows delivery on enabled channel", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
const can = await manager.canDeliver("user-1", "email", "normal");
expect(can).toBe(true);
});
it("blocks delivery on disabled channel", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setEnabled("user-1", "email", false);
const can = await manager.canDeliver("user-1", "email", "normal");
expect(can).toBe(false);
});
it("blocks delivery on non-default non-enabled channel", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
const can = await manager.canDeliver("user-1", "push", "normal");
expect(can).toBe(false);
});
it("allows delivery on default channel without explicit enable", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({
store,
defaultChannels: ["email", "push"],
});
const can = await manager.canDeliver("user-1", "push", "normal");
expect(can).toBe(true);
});
it("blocks low priority during quiet hours", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
// Pin the clock at noon UTC so the quiet-hours window is
// deterministic regardless of when CI runs.
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => noonUtc,
});
await manager.setQuietHours("user-1", {
startHour: 12,
endHour: 14,
});
const can = await manager.canDeliver("user-1", "email", "low");
expect(can).toBe(false);
});
it("allows urgent during quiet hours", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => noonUtc,
});
await manager.setQuietHours("user-1", {
startHour: 12,
endHour: 14,
});
const can = await manager.canDeliver("user-1", "email", "urgent");
expect(can).toBe(true);
});
it("allows normal priority outside quiet hours", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => noonUtc,
});
await manager.setQuietHours("user-1", {
startHour: 16,
endHour: 18,
});
const can = await manager.canDeliver("user-1", "email", "normal");
expect(can).toBe(true);
});
it("respects custom urgentThreshold", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
store.data.set("user-1", {
userId: "user-1",
enabledChannels: ["email", "in_app"],
disabledChannels: [],
quietHours: { startHour: 12, endHour: 14 },
urgentThreshold: "high",
});
const manager = new PreferenceManager({
store,
clock: () => noonUtc,
});
const canHigh = await manager.canDeliver("user-1", "email", "high");
expect(canHigh).toBe(true);
const canNormal = await manager.canDeliver(
"user-1",
"email",
"normal"
);
expect(canNormal).toBe(false);
});
it("respects quiet-hours day filter (today excluded)", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
// 2026-01-01 is a Thursday (day 4); set quiet-hours for days
// [1,2] (Mon, Tue) so today is excluded.
const noonThu = Date.UTC(2026, 0, 1, 12, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => noonThu,
});
await manager.setQuietHours("user-1", {
startHour: 12,
endHour: 14,
days: [1, 2],
});
const can = await manager.canDeliver("user-1", "email", "low");
expect(can).toBe(true);
});
it("handles wrap-around quiet hours (22→7)", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const elevenPm = Date.UTC(2026, 0, 1, 23, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => elevenPm,
});
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
});
const can = await manager.canDeliver("user-1", "email", "low");
expect(can).toBe(false);
});
it("normal priority hour outside wrap-around window", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const noon = Date.UTC(2026, 0, 1, 12, 0, 0);
const manager = new PreferenceManager({
store,
clock: () => noon,
});
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
});
const can = await manager.canDeliver("user-1", "email", "low");
expect(can).toBe(true);
});
});
describe("default clock", () => {
it("uses the built-in clock when none injected", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
// Set quiet-hours so canDeliver invokes isQuietHours and
// exercises the default Clock thunk.
await manager.setQuietHours("user-1", {
startHour: 0,
endHour: 24 - 1,
});
// Assertion shape only — wall-clock dependent boolean.
const can = await manager.canDeliver("user-1", "email", "normal");
expect(typeof can).toBe("boolean");
});
});
describe("resetPreferences", () => {
it("deletes stored preferences", { timeout: 5000 }, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
await manager.setEnabled("user-1", "sms", true);
await manager.resetPreferences("user-1");
const prefs = await manager.getPreferences("user-1");
expect(prefs.enabledChannels.has("email")).toBe(true);
expect(prefs.enabledChannels.has("in_app")).toBe(true);
expect(prefs.enabledChannels.has("sms")).toBe(false);
});
it("returns false when nothing to delete", {
timeout: 5000,
}, async () => {
const store = makeMemoryStore();
const manager = new PreferenceManager({ store });
const ok = await manager.resetPreferences("ghost");
expect(ok).toBe(false);
});
});
});

View File

@@ -0,0 +1,205 @@
import type { Clock } from "@suaveplan/types";
import type {
NotificationChannel,
NotificationPriority,
} from "../types/notification.types.js";
/** Quiet-hours configuration. */
export interface QuietHoursConfig {
/** Start hour (0-23 UTC). */
readonly startHour: number;
/** End hour (0-23 UTC). */
readonly endHour: number;
/** Days of week to apply (0=Sun, 6=Sat). Default: all days. */
readonly days?: readonly number[];
}
/** Resolved user preferences. */
export interface UserPreferences {
readonly userId: string;
readonly enabledChannels: ReadonlySet<NotificationChannel>;
readonly disabledChannels: ReadonlySet<NotificationChannel>;
readonly quietHours?: QuietHoursConfig;
readonly urgentThreshold?: NotificationPriority;
}
/** Serialised form of user preferences. */
export interface StoredPreferences {
userId: string;
enabledChannels: NotificationChannel[];
disabledChannels: NotificationChannel[];
quietHours?: QuietHoursConfig;
urgentThreshold?: NotificationPriority;
}
/** Storage adapter for user preferences. */
export interface PreferenceStoreAdapter {
get(userId: string): Promise<StoredPreferences | null>;
set(userId: string, prefs: StoredPreferences): Promise<void>;
delete(userId: string): Promise<boolean>;
}
/** Constructor options for `PreferenceManager`. */
export interface PreferenceManagerOptions {
readonly store: PreferenceStoreAdapter;
readonly defaultChannels?: readonly NotificationChannel[];
readonly clock?: Clock;
}
const PRIORITY_ORDER: Readonly<Record<NotificationPriority, number>> = {
low: 0,
normal: 1,
high: 2,
urgent: 3,
};
const defaultClock: Clock = () => Date.now();
/**
* Manages per-user notification preferences with opt-in / opt-out and
* quiet-hours support. Implements the decision at every send-time:
* "should this notification reach this user on this channel right
* now?". Storage is pluggable via `PreferenceStoreAdapter`.
*/
export class PreferenceManager {
private readonly store: PreferenceStoreAdapter;
private readonly defaults: ReadonlySet<NotificationChannel>;
private readonly clock: Clock;
public constructor(options: PreferenceManagerOptions) {
this.store = options.store;
this.defaults = new Set(options.defaultChannels ?? ["email", "in_app"]);
this.clock = options.clock ?? defaultClock;
}
/** Decide whether a notification may reach the user on this channel. */
public async canDeliver(
userId: string,
channel: NotificationChannel,
priority: NotificationPriority
): Promise<boolean> {
const prefs = await this.getPreferences(userId);
if (prefs.disabledChannels.has(channel)) return false;
if (
!prefs.enabledChannels.has(channel) &&
!this.defaults.has(channel)
) {
return false;
}
if (
prefs.quietHours !== undefined &&
this.isQuietHours(prefs.quietHours)
) {
const threshold = prefs.urgentThreshold ?? "urgent";
if (PRIORITY_ORDER[priority] < PRIORITY_ORDER[threshold]) {
return false;
}
}
return true;
}
/** Get user preferences (returns defaults if none stored). */
public async getPreferences(userId: string): Promise<UserPreferences> {
const stored = await this.store.get(userId);
if (stored === null) {
return {
userId,
enabledChannels: new Set(this.defaults),
disabledChannels: new Set(),
};
}
const result: {
-readonly [K in keyof UserPreferences]: UserPreferences[K];
} = {
userId: stored.userId,
enabledChannels: new Set(stored.enabledChannels),
disabledChannels: new Set(stored.disabledChannels),
};
if (stored.quietHours !== undefined) {
result.quietHours = stored.quietHours;
}
if (stored.urgentThreshold !== undefined) {
result.urgentThreshold = stored.urgentThreshold;
}
return result;
}
/** Enable or disable a channel for a user. */
public async setEnabled(
userId: string,
channel: NotificationChannel,
enabled: boolean
): Promise<void> {
const stored = await this.store.get(userId);
const current: StoredPreferences = stored ?? {
userId,
enabledChannels: [...this.defaults],
disabledChannels: [],
};
const enabledSet = new Set(current.enabledChannels);
const disabledSet = new Set(current.disabledChannels);
if (enabled) {
enabledSet.add(channel);
disabledSet.delete(channel);
} else {
enabledSet.delete(channel);
disabledSet.add(channel);
}
await this.store.set(userId, {
...current,
enabledChannels: [...enabledSet],
disabledChannels: [...disabledSet],
});
}
/** Set or clear quiet-hours for a user. */
public async setQuietHours(
userId: string,
quietHours: QuietHoursConfig | null
): Promise<void> {
const stored = await this.store.get(userId);
const current: StoredPreferences = stored ?? {
userId,
enabledChannels: [...this.defaults],
disabledChannels: [],
};
const next: StoredPreferences = {
userId: current.userId,
enabledChannels: current.enabledChannels,
disabledChannels: current.disabledChannels,
};
if (current.urgentThreshold !== undefined) {
next.urgentThreshold = current.urgentThreshold;
}
if (quietHours !== null) {
next.quietHours = quietHours;
}
await this.store.set(userId, next);
}
/** Delete all preferences for a user (resets to defaults). */
public async resetPreferences(userId: string): Promise<boolean> {
return this.store.delete(userId);
}
private isQuietHours(config: QuietHoursConfig): boolean {
const now = new Date(this.clock());
const hour = now.getUTCHours();
const day = now.getUTCDay();
if (config.days !== undefined && !config.days.includes(day)) {
return false;
}
if (config.startHour <= config.endHour) {
return hour >= config.startHour && hour < config.endHour;
}
return hour >= config.startHour || hour < config.endHour;
}
}

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,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import { ChannelRateLimiter } from "./channel-rate-limiter.js";
describe("ChannelRateLimiter", () => {
describe("channel limits", () => {
it("allows sends within limit", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 3, windowMs: 60_000 });
expect(() =>
limiter.checkAndRecord("email", "user1")
).not.toThrow();
expect(() =>
limiter.checkAndRecord("email", "user2")
).not.toThrow();
expect(() =>
limiter.checkAndRecord("email", "user3")
).not.toThrow();
});
it("throws when channel limit exceeded", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 2, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
limiter.checkAndRecord("email", "user2");
expect(() => limiter.checkAndRecord("email", "user3")).toThrow(
NotificationChannelError
);
});
it("allows sends after window expires", { timeout: 5000 }, () => {
let now = 1_000_000;
const limiter = new ChannelRateLimiter(() => now);
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 1_000 });
limiter.checkAndRecord("email", "user1");
now += 1_001;
expect(() =>
limiter.checkAndRecord("email", "user2")
).not.toThrow();
});
it("does not limit when no limit set", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
for (let i = 0; i < 100; i++) {
expect(() =>
limiter.checkAndRecord("email", `user${i}`)
).not.toThrow();
}
});
it("removes channel limit", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
limiter.removeChannelLimit("email");
expect(() =>
limiter.checkAndRecord("email", "user2")
).not.toThrow();
});
});
describe("user limits", () => {
it("allows sends within user limit", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setUserLimit("user1", { maxCount: 2, windowMs: 60_000 });
expect(() =>
limiter.checkAndRecord("email", "user1")
).not.toThrow();
expect(() => limiter.checkAndRecord("sms", "user1")).not.toThrow();
});
it("throws when user limit exceeded", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
expect(() => limiter.checkAndRecord("sms", "user1")).toThrow(
NotificationChannelError
);
});
it("removes user limit", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
limiter.removeUserLimit("user1");
expect(() =>
limiter.checkAndRecord("email", "user1")
).not.toThrow();
});
});
describe("isAllowed", () => {
it("returns true when within limits", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 5, windowMs: 60_000 });
expect(limiter.isAllowed("email", "user1")).toBe(true);
});
it("returns false when channel limit reached", {
timeout: 5000,
}, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
expect(limiter.isAllowed("email", "user2")).toBe(false);
});
it("returns false when user limit reached", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
expect(limiter.isAllowed("sms", "user1")).toBe(false);
});
it("returns true for an unconfigured user", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
expect(limiter.isAllowed("email", "user-without-limit")).toBe(true);
});
});
describe("remaining capacity", () => {
it("returns remaining channel capacity", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 5, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
limiter.checkAndRecord("email", "user2");
expect(limiter.getChannelRemaining("email")).toBe(3);
});
it("returns Infinity when no limit set", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
expect(limiter.getChannelRemaining("email")).toBe(
Number.POSITIVE_INFINITY
);
});
it("returns remaining user capacity", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setUserLimit("user1", { maxCount: 3, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
expect(limiter.getUserRemaining("user1")).toBe(2);
});
it("returns Infinity for user without limit", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
expect(limiter.getUserRemaining("user1")).toBe(
Number.POSITIVE_INFINITY
);
});
it("returns zero when capacity exhausted", { timeout: 5000 }, () => {
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
limiter.checkAndRecord("email", "user1");
expect(limiter.getChannelRemaining("email")).toBe(0);
});
});
});

View File

@@ -0,0 +1,203 @@
import type { Clock } from "@suaveplan/types";
import { RateLimitExceededError } from "../error-codes/error-codes.js";
import type { NotificationChannel } from "../types/notification.types.js";
/** Configuration for a rate-limit rule. */
export interface RateLimitRule {
/** Maximum number of notifications allowed in the window. */
readonly maxCount: number;
/** Window size in milliseconds. */
readonly windowMs: number;
}
interface WindowEntry {
readonly timestamp: number;
}
const defaultClock: Clock = () => Date.now();
/**
* Sliding-window rate limiter for notification channels. Supports
* per-channel limits (every send against that channel counts toward
* the channel's bucket) and per-user limits (every send for that user
* counts toward the user's bucket, regardless of channel). Both
* buckets accept an injectable `Clock` so tests can advance time
* without sleeping.
*/
export class ChannelRateLimiter {
private readonly channelLimits = new Map<
NotificationChannel,
RateLimitRule
>();
private readonly userLimits = new Map<string, RateLimitRule>();
private readonly channelWindows = new Map<
NotificationChannel,
WindowEntry[]
>();
private readonly userWindows = new Map<string, WindowEntry[]>();
private readonly clock: Clock;
public constructor(clock: Clock = defaultClock) {
this.clock = clock;
}
/** Set a rate limit for a specific channel. */
public setChannelLimit(
channel: NotificationChannel,
rule: RateLimitRule
): void {
this.channelLimits.set(channel, rule);
}
/** Set a rate limit for a specific user. */
public setUserLimit(userId: string, rule: RateLimitRule): void {
this.userLimits.set(userId, rule);
}
/** Remove a channel rate limit. */
public removeChannelLimit(channel: NotificationChannel): void {
this.channelLimits.delete(channel);
this.channelWindows.delete(channel);
}
/** Remove a user rate limit. */
public removeUserLimit(userId: string): void {
this.userLimits.delete(userId);
this.userWindows.delete(userId);
}
/**
* Check + record a send attempt against both buckets. Throws
* `RateLimitExceededError` when either bucket would overflow.
*/
public checkAndRecord(channel: NotificationChannel, userId: string): void {
const now = this.clock();
this.checkChannelLimit(channel, now);
this.checkUserLimit(channel, userId, now);
this.recordChannel(channel, now);
this.recordUser(userId, now);
}
/** Probe whether `checkAndRecord` would succeed (does not record). */
public isAllowed(channel: NotificationChannel, userId: string): boolean {
const now = this.clock();
return (
this.isChannelAllowed(channel, now) &&
this.isUserAllowed(userId, now)
);
}
/** Remaining capacity in the channel bucket. */
public getChannelRemaining(channel: NotificationChannel): number {
const rule = this.channelLimits.get(channel);
if (rule === undefined) return Number.POSITIVE_INFINITY;
const now = this.clock();
const entries = this.getValidEntries(
this.channelWindows.get(channel),
now,
rule.windowMs
);
return Math.max(0, rule.maxCount - entries.length);
}
/** Remaining capacity in the user bucket. */
public getUserRemaining(userId: string): number {
const rule = this.userLimits.get(userId);
if (rule === undefined) return Number.POSITIVE_INFINITY;
const now = this.clock();
const entries = this.getValidEntries(
this.userWindows.get(userId),
now,
rule.windowMs
);
return Math.max(0, rule.maxCount - entries.length);
}
private checkChannelLimit(channel: NotificationChannel, now: number): void {
const rule = this.channelLimits.get(channel);
if (rule === undefined) return;
const entries = this.getValidEntries(
this.channelWindows.get(channel),
now,
rule.windowMs
);
if (entries.length >= rule.maxCount) {
throw new RateLimitExceededError({
context: { channel, scope: "channel" },
});
}
}
private checkUserLimit(
channel: NotificationChannel,
userId: string,
now: number
): void {
const rule = this.userLimits.get(userId);
if (rule === undefined) return;
const entries = this.getValidEntries(
this.userWindows.get(userId),
now,
rule.windowMs
);
if (entries.length >= rule.maxCount) {
throw new RateLimitExceededError({
context: { channel, userId, scope: "user" },
});
}
}
private isChannelAllowed(
channel: NotificationChannel,
now: number
): boolean {
const rule = this.channelLimits.get(channel);
if (rule === undefined) return true;
const entries = this.getValidEntries(
this.channelWindows.get(channel),
now,
rule.windowMs
);
return entries.length < rule.maxCount;
}
private isUserAllowed(userId: string, now: number): boolean {
const rule = this.userLimits.get(userId);
if (rule === undefined) return true;
const entries = this.getValidEntries(
this.userWindows.get(userId),
now,
rule.windowMs
);
return entries.length < rule.maxCount;
}
private recordChannel(channel: NotificationChannel, now: number): void {
if (!this.channelLimits.has(channel)) return;
const entries = this.channelWindows.get(channel) ?? [];
entries.push({ timestamp: now });
this.channelWindows.set(channel, entries);
}
private recordUser(userId: string, now: number): void {
if (!this.userLimits.has(userId)) return;
const entries = this.userWindows.get(userId) ?? [];
entries.push({ timestamp: now });
this.userWindows.set(userId, entries);
}
private getValidEntries(
entries: WindowEntry[] | undefined,
now: number,
windowMs: number
): WindowEntry[] {
if (entries === undefined) return [];
return entries.filter((e) => now - e.timestamp < windowMs);
}
}

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,237 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
DeliveryChannel,
DeliveryResult,
} from "../channels/delivery-channel.js";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import type { Notification } from "../types/notification.types.js";
import { DeliveryRetry } from "./delivery-retry.js";
function makeNotification(overrides: Partial<Notification> = {}): Notification {
return {
id: "notif_1",
channel: "email",
recipient: "user@example.com",
body: "Test",
priority: "normal",
status: "sending",
createdAt: new Date().toISOString(),
...overrides,
};
}
function makeChannel(results: DeliveryResult[]): DeliveryChannel {
let callIndex = 0;
return {
name: "test",
canSend: () => true,
send: async () => {
const r = results[callIndex];
callIndex++;
if (r === undefined) {
throw new Error("ran out of scripted results");
}
return r;
},
};
}
function successResult(): DeliveryResult {
return {
success: true,
channel: "test",
messageId: "msg_1",
timestamp: new Date().toISOString(),
};
}
function failResult(error = "Failed"): DeliveryResult {
return {
success: false,
channel: "test",
error,
timestamp: new Date().toISOString(),
};
}
const noSleep = async (_ms: number): Promise<void> => {
/* tests skip delays */
};
describe("DeliveryRetry", () => {
it("returns result on first success", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 3, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([successResult()]);
const result = await retry.sendWithRetry(channel, makeNotification());
expect(result.success).toBe(true);
});
it("retries on failure then succeeds", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 3, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([
failResult(),
failResult(),
successResult(),
]);
const result = await retry.sendWithRetry(channel, makeNotification());
expect(result.success).toBe(true);
});
it("throws after exhausting retries", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 2, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([
failResult("err1"),
failResult("err2"),
failResult("err3"),
]);
await expect(
retry.sendWithRetry(channel, makeNotification())
).rejects.toThrow(NotificationChannelError);
});
it("adds to dead letter queue on exhaustion", {
timeout: 5000,
}, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 1, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([failResult("fail1"), failResult("fail2")]);
try {
await retry.sendWithRetry(channel, makeNotification());
} catch {
// expected
}
const deadLetters = retry.getDeadLetters();
expect(deadLetters).toHaveLength(1);
expect(deadLetters[0]?.lastError).toBe("fail2");
expect(deadLetters[0]?.attempts).toBe(2);
});
it("calls dead letter handler", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 0, baseDelayMs: 1 },
() => 0,
noSleep
);
let handlerCalled = false;
retry.onDeadLetter(() => {
handlerCalled = true;
});
const channel = makeChannel([failResult()]);
try {
await retry.sendWithRetry(channel, makeNotification());
} catch {
// expected
}
expect(handlerCalled).toBe(true);
});
it("clears dead letters", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 0, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([failResult()]);
try {
await retry.sendWithRetry(channel, makeNotification());
} catch {
// expected
}
expect(retry.getDeadLetters()).toHaveLength(1);
retry.clearDeadLetters();
expect(retry.getDeadLetters()).toHaveLength(0);
});
it("uses 'Unknown delivery error' when result has no error", {
timeout: 5000,
}, async () => {
const retry = new DeliveryRetry(
{ maxRetries: 0, baseDelayMs: 1 },
() => 0,
noSleep
);
const channel = makeChannel([
{
success: false,
channel: "test",
timestamp: new Date().toISOString(),
},
]);
try {
await retry.sendWithRetry(channel, makeNotification());
} catch {
// expected
}
const deadLetters = retry.getDeadLetters();
expect(deadLetters[0]?.lastError).toBe("Unknown delivery error");
});
describe("calculateDelay", () => {
it("calculates exponential backoff", { timeout: 5000 }, () => {
const retry = new DeliveryRetry({
maxRetries: 3,
baseDelayMs: 1_000,
});
expect(retry.calculateDelay(1)).toBe(1_000);
expect(retry.calculateDelay(2)).toBe(2_000);
expect(retry.calculateDelay(3)).toBe(4_000);
});
it("caps delay at maxDelayMs", { timeout: 5000 }, () => {
const retry = new DeliveryRetry({
maxRetries: 10,
baseDelayMs: 1_000,
maxDelayMs: 5_000,
});
expect(retry.calculateDelay(10)).toBe(5_000);
});
it("uses default maxDelayMs of 30_000", { timeout: 5000 }, () => {
const retry = new DeliveryRetry({
maxRetries: 10,
baseDelayMs: 10_000,
});
expect(retry.calculateDelay(5)).toBe(30_000);
});
});
it("uses default config when none provided", { timeout: 5000 }, () => {
const retry = new DeliveryRetry();
expect(retry.calculateDelay(1)).toBe(1_000);
});
it("uses default clock when none injected", { timeout: 5000 }, async () => {
const retry = new DeliveryRetry({ maxRetries: 0, baseDelayMs: 1 });
const channel = makeChannel([failResult("boom")]);
try {
await retry.sendWithRetry(channel, makeNotification());
} catch {
// expected
}
const entry = retry.getDeadLetters()[0];
expect(entry?.failedAt).toBeDefined();
});
it("default sleeper runs without throwing", { timeout: 5000 }, async () => {
// Cover the default sleep code path; 1ms keeps the test fast.
const retry = new DeliveryRetry({ maxRetries: 1, baseDelayMs: 1 });
const channel = makeChannel([failResult(), successResult()]);
const result = await retry.sendWithRetry(channel, makeNotification());
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,143 @@
import type { Clock } from "@suaveplan/types";
import type {
DeliveryChannel,
DeliveryResult,
} from "../channels/delivery-channel.js";
import { DeliveryFailedError } from "../error-codes/error-codes.js";
import type { Notification } from "../types/notification.types.js";
/** Configuration for retry behaviour. */
export interface RetryConfig {
/** Maximum number of retry attempts (default: 3). */
readonly maxRetries: number;
/** Base delay in ms for exponential backoff (default: 1_000). */
readonly baseDelayMs: number;
/** Maximum delay cap in ms (default: 30_000). */
readonly maxDelayMs?: number;
}
/** Record of a failed delivery that exhausted retries. */
export interface DeadLetterEntry {
readonly notification: Notification;
readonly channel: string;
readonly attempts: number;
readonly lastError: string;
readonly failedAt: string;
}
/** Callback for dead-letter events. */
export type DeadLetterHandler = (entry: DeadLetterEntry) => void;
/** Sleep adapter; tests inject a no-op or fake-timer-driven sleeper. */
export type SleepFn = (ms: number) => Promise<void>;
const DEFAULT_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1_000,
maxDelayMs: 30_000,
};
const defaultClock: Clock = () => Date.now();
const defaultSleep: SleepFn = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Wraps a `DeliveryChannel` with exponential-backoff retry and a
* dead-letter queue. The wrapper:
*
* 1. Calls the channel up to `maxRetries + 1` times.
* 2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`)
* between attempts via the injectable `SleepFn`.
* 3. On exhaustion, pushes a `DeadLetterEntry`, fires the optional
* handler, and throws `DeliveryFailedError`.
*/
export class DeliveryRetry {
private readonly config: RetryConfig;
private readonly deadLetters: DeadLetterEntry[] = [];
private deadLetterHandler?: DeadLetterHandler;
private readonly clock: Clock;
private readonly sleep: SleepFn;
public constructor(
config?: Partial<RetryConfig>,
clock: Clock = defaultClock,
sleep: SleepFn = defaultSleep
) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.clock = clock;
this.sleep = sleep;
}
/** Register a handler called when an entry hits the dead-letter queue. */
public onDeadLetter(handler: DeadLetterHandler): void {
this.deadLetterHandler = handler;
}
/** Snapshot of the current dead-letter queue. */
public getDeadLetters(): readonly DeadLetterEntry[] {
return this.deadLetters;
}
/** Clear the dead-letter queue. */
public clearDeadLetters(): void {
this.deadLetters.length = 0;
}
/**
* Attempt delivery with retries using exponential backoff.
* Returns the successful result or throws after exhausting retries.
*/
public async sendWithRetry(
channel: DeliveryChannel,
notification: Notification
): Promise<DeliveryResult> {
let lastError = "";
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
if (attempt > 0) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
}
const result = await channel.send(notification);
if (result.success) {
return result;
}
lastError = result.error ?? "Unknown delivery error";
}
const entry: DeadLetterEntry = {
notification,
channel: channel.name,
attempts: this.config.maxRetries + 1,
lastError,
failedAt: new Date(this.clock()).toISOString(),
};
this.deadLetters.push(entry);
if (this.deadLetterHandler !== undefined) {
this.deadLetterHandler(entry);
}
throw new DeliveryFailedError({
cause: new Error(lastError),
context: {
notificationId: notification.id,
channel: channel.name,
attempts: entry.attempts,
},
});
}
/** Delay for `attempt` (1-indexed) using exponential backoff. */
public calculateDelay(attempt: number): number {
const delay = this.config.baseDelayMs * 2 ** (attempt - 1);
// `DEFAULT_CONFIG.maxDelayMs` is always merged into `this.config`
// via the constructor spread, so the field is always defined; the
// `as number` cast keeps `noUncheckedIndexedAccess` happy without
// introducing an unreachable `??` branch.
const maxDelay = this.config.maxDelayMs as number;
return Math.min(delay, maxDelay);
}
}

View File

@@ -0,0 +1,282 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import { notificationHooks, onNotificationRouting } from "../hooks/hooks.js";
import type {
ChannelConfig,
RoutingRule,
} from "../types/notification.types.js";
import { NotificationRouter } from "./notification-router.js";
function makeRouter(
options: {
channels?: ChannelConfig[];
routingRules?: RoutingRule[];
clock?: () => number;
} = {}
): NotificationRouter {
return new NotificationRouter({
channels: options.channels ?? [
{ channel: "email", enabled: true },
{ channel: "sms", enabled: true },
{ channel: "push", enabled: true },
{ channel: "in_app", enabled: true },
],
...(options.routingRules !== undefined
? { routingRules: options.routingRules }
: {}),
defaultChannel: "email",
...(options.clock !== undefined ? { clock: options.clock } : {}),
});
}
describe("NotificationRouter — rate limiting", () => {
it("enforces rate limit per channel", { timeout: 5000 }, async () => {
const router = makeRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 2 }],
});
await router.send({
recipient: "a@a.com",
body: "A",
channel: "email",
});
await router.send({
recipient: "b@b.com",
body: "B",
channel: "email",
});
await expect(
router.send({
recipient: "c@c.com",
body: "C",
channel: "email",
})
).rejects.toThrow(NotificationChannelError);
});
it("does not rate limit when no limit set", {
timeout: 5000,
}, async () => {
const router = makeRouter();
for (let i = 0; i < 50; i++) {
await router.send({
recipient: `u${i}@a.com`,
body: "msg",
channel: "email",
});
}
const stats = await router.getChannelStats();
expect(stats.email.sent).toBe(50);
});
it("resets rate-limit window after the 60s window", {
timeout: 5000,
}, async () => {
let now = 1_000_000;
const router = makeRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 1 }],
clock: () => now,
});
await router.send({
recipient: "a@a.com",
body: "A",
channel: "email",
});
now += 61_000;
const result = await router.send({
recipient: "b@b.com",
body: "B",
channel: "email",
});
expect(result.status).toBe("sent");
});
});
describe("NotificationRouter — routing rules", () => {
it("routes based on priority rule", { timeout: 5000 }, async () => {
const router = makeRouter({
routingRules: [
{
id: "r1",
name: "Urgent to SMS",
channels: ["sms"],
priority: "urgent",
},
],
});
const result = await router.send({
recipient: "+15551234567",
body: "Urgent!",
priority: "urgent",
});
expect(result.channel).toBe("sms");
});
it("routes based on condition", { timeout: 5000 }, async () => {
const router = makeRouter({
routingRules: [
{
id: "r1",
name: "VIP to push",
channels: ["push"],
condition: (recipient) => recipient.startsWith("vip_"),
},
],
});
const result = await router.send({
recipient: "vip_user",
body: "VIP notification",
});
expect(result.channel).toBe("push");
});
it("falls back to default when no rule matches", {
timeout: 5000,
}, async () => {
const router = makeRouter({
routingRules: [
{
id: "r1",
name: "Urgent to SMS",
channels: ["sms"],
priority: "urgent",
},
],
});
const result = await router.send({
recipient: "user@example.com",
body: "Normal",
priority: "normal",
});
expect(result.channel).toBe("email");
});
it("adds routing rules dynamically", { timeout: 5000 }, async () => {
const router = makeRouter();
await router.addRoutingRule({
id: "r2",
name: "All to in_app",
channels: ["in_app"],
});
const result = await router.send({
recipient: "user",
body: "Hello",
});
expect(result.channel).toBe("in_app");
});
it("skips rules with non-matching conditions", {
timeout: 5000,
}, async () => {
const router = makeRouter({
routingRules: [
{
id: "r1",
name: "Admin only",
channels: ["push"],
condition: (r) => r === "admin",
},
],
});
const result = await router.send({
recipient: "regular_user",
body: "Hello",
});
expect(result.channel).toBe("email");
});
it("uses first matching rule channel", { timeout: 5000 }, async () => {
const router = makeRouter({
routingRules: [
{ id: "r1", name: "First", channels: ["sms", "push"] },
],
});
const result = await router.send({
recipient: "user",
body: "Hello",
});
expect(result.channel).toBe("sms");
});
it("falls back to default when rule has empty channel list", {
timeout: 5000,
}, async () => {
const router = makeRouter({
routingRules: [{ id: "r1", name: "empty", channels: [] }],
});
const result = await router.send({
recipient: "user",
body: "Hello",
});
expect(result.channel).toBe("email");
});
});
describe("NotificationRouter — priority handling", () => {
it("stores notification with priority", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "Urgent!",
priority: "urgent",
});
const notif = await router.getNotification(result.id);
expect(notif.priority).toBe("urgent");
});
it("defaults priority to normal", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "Hi",
});
const notif = await router.getNotification(result.id);
expect(notif.priority).toBe("normal");
});
it("stores low priority", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "Low",
priority: "low",
});
const notif = await router.getNotification(result.id);
expect(notif.priority).toBe("low");
});
it("stores high priority", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "High",
priority: "high",
});
const notif = await router.getNotification(result.id);
expect(notif.priority).toBe("high");
});
});
describe("NotificationRouter — hook integration", () => {
it("notification:routing hook can override resolved channel", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const off = onNotificationRouting((data) => {
if (data.input.priority === "urgent") {
return { resolvedChannel: "sms" };
}
return undefined;
});
try {
const result = await router.send({
recipient: "+15551234567",
body: "Urgent!",
priority: "urgent",
});
expect(result.channel).toBe("sms");
} finally {
off();
notificationHooks.clear("notification:routing");
}
});
});

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,534 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
DeliveryChannel,
DeliveryResult,
} from "../channels/delivery-channel.js";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import type {
ChannelConfig,
Notification,
NotificationTemplate,
} from "../types/notification.types.js";
import { NotificationRouter } from "./notification-router.js";
function makeRouter(
overrides: {
channels?: ChannelConfig[];
templates?: NotificationTemplate[];
} = {}
): NotificationRouter {
return new NotificationRouter({
channels: overrides.channels ?? [
{ channel: "email", enabled: true },
{ channel: "sms", enabled: true },
{ channel: "push", enabled: true },
{ channel: "in_app", enabled: true },
],
...(overrides.templates !== undefined
? { templates: overrides.templates }
: {}),
defaultChannel: "email",
});
}
describe("NotificationRouter — send + lifecycle", () => {
it("sends a notification to a channel", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "user@example.com",
body: "Hello",
channel: "email",
});
expect(result.status).toBe("sent");
expect(result.channel).toBe("email");
expect(result.id).toBeDefined();
});
it("uses default channel when not specified", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "user@example.com",
body: "Hello",
});
expect(result.channel).toBe("email");
});
it("includes sentAt timestamp", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "user@example.com",
body: "Hello",
});
expect(result.sentAt).toBeDefined();
});
it("emits notification.sent event", { timeout: 5000 }, async () => {
const router = makeRouter();
let emitted = false;
router.on("notification.sent", () => {
emitted = true;
});
await router.send({ recipient: "user@example.com", body: "Hello" });
expect(emitted).toBe(true);
});
it("throws when channel is disabled", { timeout: 5000 }, async () => {
const router = makeRouter({
channels: [{ channel: "email", enabled: false }],
});
await expect(
router.send({
recipient: "user@example.com",
body: "Hello",
channel: "email",
})
).rejects.toThrow(NotificationChannelError);
});
it("throws when channel is not registered", {
timeout: 5000,
}, async () => {
const router = makeRouter({
channels: [{ channel: "email", enabled: true }],
});
await expect(
router.send({
recipient: "user@example.com",
body: "Hello",
channel: "webhook",
})
).rejects.toThrow(NotificationChannelError);
});
it("sends to sms channel", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "+15551234567",
body: "Hello SMS",
channel: "sms",
});
expect(result.channel).toBe("sms");
});
it("sends with subject", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "user@example.com",
body: "Hello",
subject: "Test Subject",
});
const notif = await router.getNotification(result.id);
expect(notif.subject).toBe("Test Subject");
});
it("sends with metadata", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "user@example.com",
body: "Hello",
metadata: { source: "test" },
});
const notif = await router.getNotification(result.id);
expect(notif.metadata).toEqual({ source: "test" });
});
it("assigns unique ids to each notification", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const r1 = await router.send({ recipient: "a@a.com", body: "A" });
const r2 = await router.send({ recipient: "b@b.com", body: "B" });
expect(r1.id).not.toBe(r2.id);
});
it("sendBatch returns one result per input", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const results = await router.sendBatch([
{ recipient: "a@a.com", body: "Hello A" },
{ recipient: "b@b.com", body: "Hello B" },
{ recipient: "c@c.com", body: "Hello C" },
]);
expect(results).toHaveLength(3);
expect(results.every((r) => r.status === "sent")).toBe(true);
});
it("sendBatch returns empty for empty input", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const results = await router.sendBatch([]);
expect(results).toHaveLength(0);
});
it("retrieves a sent notification", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "Hello",
});
const notif = await router.getNotification(result.id);
expect(notif.body).toBe("Hello");
expect(notif.recipient).toBe("a@a.com");
});
it("getNotification throws for non-existent id", {
timeout: 5000,
}, async () => {
const router = makeRouter();
await expect(router.getNotification("fake")).rejects.toThrow(
NotificationChannelError
);
});
it("enqueue creates a pending notification", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const result = await router.enqueue({
recipient: "a@a.com",
body: "Hello",
});
expect(result.status).toBe("pending");
const notif = await router.getNotification(result.id);
expect(notif.status).toBe("pending");
});
it("enqueue records subject and metadata when provided", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const result = await router.enqueue({
recipient: "a@a.com",
body: "Hi",
subject: "Pending subject",
metadata: { trace: "t-1" },
});
const notif = await router.getNotification(result.id);
expect(notif.subject).toBe("Pending subject");
expect(notif.metadata).toEqual({ trace: "t-1" });
});
it("enqueue emits notification.created event", {
timeout: 5000,
}, async () => {
const router = makeRouter();
let emitted = false;
router.on("notification.created", () => {
emitted = true;
});
await router.enqueue({ recipient: "a@a.com", body: "Hello" });
expect(emitted).toBe(true);
});
it("cancels a pending notification", { timeout: 5000 }, async () => {
const router = makeRouter();
const result = await router.enqueue({
recipient: "a@a.com",
body: "Hello",
});
const cancelled = await router.cancel(result.id);
expect(cancelled.status).toBe("cancelled");
});
it("emits notification.cancelled event", { timeout: 5000 }, async () => {
const router = makeRouter();
let emitted = false;
router.on("notification.cancelled", () => {
emitted = true;
});
const result = await router.enqueue({
recipient: "a@a.com",
body: "Hello",
});
await router.cancel(result.id);
expect(emitted).toBe(true);
});
it("throws when cancelling sent notification", {
timeout: 5000,
}, async () => {
const router = makeRouter();
const result = await router.send({
recipient: "a@a.com",
body: "Hello",
});
await expect(router.cancel(result.id)).rejects.toThrow(
NotificationChannelError
);
});
it("cancel throws for non-existent id", { timeout: 5000 }, async () => {
const router = makeRouter();
await expect(router.cancel("fake")).rejects.toThrow(
NotificationChannelError
);
});
it("getChannelStats reports per-channel sent count", {
timeout: 5000,
}, async () => {
const router = makeRouter();
await router.send({
recipient: "a@a.com",
body: "A",
channel: "email",
});
await router.send({
recipient: "b@b.com",
body: "B",
channel: "sms",
});
const stats = await router.getChannelStats();
expect(stats.email.sent).toBe(1);
expect(stats.sms.sent).toBe(1);
expect(stats.email.enabled).toBe(true);
});
it("registerChannel overrides existing channel config", {
timeout: 5000,
}, async () => {
const router = makeRouter();
await router.registerChannel({ channel: "email", enabled: false });
await expect(
router.send({
recipient: "a@a.com",
body: "Hello",
channel: "email",
})
).rejects.toThrow(NotificationChannelError);
});
it("registerChannel can add a new channel", { timeout: 5000 }, async () => {
const router = makeRouter({ channels: [] });
await router.registerChannel({ channel: "webhook", enabled: true });
const result = await router.send({
recipient: "https://hook.example.com",
body: "{}",
channel: "webhook",
});
expect(result.channel).toBe("webhook");
});
it("registerTemplate registers and uses a template", {
timeout: 5000,
}, async () => {
const router = makeRouter();
await router.registerTemplate({
id: "tpl_welcome",
name: "Welcome",
channel: "email",
subject: "Welcome {{name}}",
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
defaultPriority: "normal",
});
const result = await router.send({
recipient: "a@a.com",
body: "",
templateId: "tpl_welcome",
templateData: { name: "Alice", app: "Suaveplan" },
});
const notif = await router.getNotification(result.id);
expect(notif.body).toBe("Hello Alice, welcome to Suaveplan!");
expect(notif.subject).toBe("Welcome Alice");
});
it("templates render body with variables", { timeout: 5000 }, async () => {
const router = makeRouter({
templates: [
{
id: "tpl_1",
name: "Test",
channel: "email",
bodyTemplate: "Hi {{user}}, code: {{code}}",
defaultPriority: "normal",
},
],
});
const result = await router.send({
recipient: "a@a.com",
body: "",
templateId: "tpl_1",
templateData: { user: "Bob", code: "1234" },
});
const notif = await router.getNotification(result.id);
expect(notif.body).toBe("Hi Bob, code: 1234");
});
it("templates throws for non-existent template", {
timeout: 5000,
}, async () => {
const router = makeRouter();
await expect(
router.send({
recipient: "a@a.com",
body: "",
templateId: "fake",
})
).rejects.toThrow(NotificationChannelError);
});
it("uses input subject over template subject", {
timeout: 5000,
}, async () => {
const router = makeRouter({
templates: [
{
id: "tpl_1",
name: "Test",
channel: "email",
subject: "Template Subject",
bodyTemplate: "Body",
defaultPriority: "normal",
},
],
});
const result = await router.send({
recipient: "a@a.com",
body: "",
subject: "Custom Subject",
templateId: "tpl_1",
});
const notif = await router.getNotification(result.id);
expect(notif.subject).toBe("Custom Subject");
});
it("renders template without data", { timeout: 5000 }, async () => {
const router = makeRouter({
templates: [
{
id: "tpl_1",
name: "Test",
channel: "email",
bodyTemplate: "Static body",
defaultPriority: "normal",
},
],
});
const result = await router.send({
recipient: "a@a.com",
body: "",
templateId: "tpl_1",
});
const notif = await router.getNotification(result.id);
expect(notif.body).toBe("Static body");
});
it("dispatches to registered DeliveryChannel and reports failure", {
timeout: 5000,
}, async () => {
const failing: DeliveryChannel = {
name: "email",
canSend: () => true,
send: async (): Promise<DeliveryResult> => ({
success: false,
channel: "email",
error: "downstream went boom",
timestamp: new Date().toISOString(),
}),
};
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
deliveryChannels: [failing],
});
let failedEvent: Notification | undefined;
router.on("notification.failed", (n) => {
failedEvent = n;
});
const result = await router.send({
recipient: "a@a.com",
body: "Hello",
});
expect(result.status).toBe("failed");
expect(result.error).toBe("downstream went boom");
expect(failedEvent?.status).toBe("failed");
});
it("dispatches failure without an error string when channel omits one", {
timeout: 5000,
}, async () => {
const silentlyFailing: DeliveryChannel = {
name: "email",
canSend: () => true,
send: async (): Promise<DeliveryResult> => ({
success: false,
channel: "email",
timestamp: new Date().toISOString(),
}),
};
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
deliveryChannels: [silentlyFailing],
});
const result = await router.send({
recipient: "a@a.com",
body: "Hello",
});
expect(result.status).toBe("failed");
expect(result.error).toBeUndefined();
});
it("dispatches to registered DeliveryChannel on success", {
timeout: 5000,
}, async () => {
const ok: DeliveryChannel = {
name: "email",
canSend: () => true,
send: async (): Promise<DeliveryResult> => ({
success: true,
channel: "email",
messageId: "msg-x",
timestamp: new Date().toISOString(),
}),
};
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
deliveryChannels: [ok],
});
const result = await router.send({
recipient: "a@a.com",
body: "Hello",
});
expect(result.status).toBe("sent");
});
it("registerDeliveryChannel adds runtime delivery channel", {
timeout: 5000,
}, async () => {
const router = makeRouter();
let invoked = false;
router.registerDeliveryChannel({
name: "email",
canSend: () => true,
send: async () => {
invoked = true;
return {
success: true,
channel: "email",
messageId: "ok",
timestamp: new Date().toISOString(),
};
},
});
await router.send({ recipient: "a@a.com", body: "Hi" });
expect(invoked).toBe(true);
});
it("Zod boundary rejects malformed router config", {
timeout: 5000,
}, () => {
expect(
() =>
new NotificationRouter({
channels: "wrong" as unknown as ChannelConfig[],
defaultChannel: "email",
})
).toThrow();
});
});

View File

@@ -0,0 +1,405 @@
import { EventBus } from "@suaveplan/events";
import type { Clock } from "@suaveplan/types";
import type { DeliveryChannel } from "../channels/delivery-channel.js";
import {
ChannelDisabledError,
NotificationNotFoundError,
RateLimitExceededError,
RoutingFailedError,
TemplateNotFoundError,
} from "../error-codes/error-codes.js";
import { notificationHooks } from "../hooks/hooks.js";
import { recordDeliveryAttempt, recordRouted } from "../otel/metrics.js";
import { NotificationRouterConfigSchema } from "../schemas/schemas.js";
import type {
ChannelConfig,
Notification,
NotificationChannel,
NotificationEventMap,
NotificationInput,
NotificationResult,
NotificationTemplate,
RoutingRule,
} from "../types/notification.types.js";
import { VALID_TRANSITIONS } from "../types/notification.types.js";
/** Public configuration for `NotificationRouter`. */
export interface NotificationRouterConfig {
readonly channels: readonly ChannelConfig[];
readonly templates?: readonly NotificationTemplate[];
readonly routingRules?: readonly RoutingRule[];
readonly defaultChannel: NotificationChannel;
readonly deliveryChannels?: readonly DeliveryChannel[];
readonly clock?: Clock;
}
interface ChannelState {
config: ChannelConfig;
sentCount: number;
windowStart: number;
}
const defaultClock: Clock = () => Date.now();
/**
* Routes notifications across channels with template rendering,
* rate-limit gating, lifecycle tracking, and EventBus emission. The
* router is the single integration point a consumer talks to;
* everything else (rate limiter, scheduler, retry, preferences) is
* composable on top.
*/
export class NotificationRouter {
private readonly notifications = new Map<string, Notification>();
private readonly channelStates = new Map<
NotificationChannel,
ChannelState
>();
private readonly templates = new Map<string, NotificationTemplate>();
private readonly routingRules: RoutingRule[] = [];
private readonly deliveryChannels = new Map<string, DeliveryChannel>();
private readonly defaultChannel: NotificationChannel;
private readonly clock: Clock;
private readonly bus = new EventBus<NotificationEventMap>();
private nextId = 1;
public constructor(config: NotificationRouterConfig) {
// Zod boundary — reject malformed config before any state mutates.
NotificationRouterConfigSchema.parse({
channels: config.channels,
...(config.templates !== undefined
? { templates: config.templates }
: {}),
...(config.routingRules !== undefined
? { routingRules: config.routingRules }
: {}),
defaultChannel: config.defaultChannel,
...(config.deliveryChannels !== undefined
? { deliveryChannels: config.deliveryChannels }
: {}),
...(config.clock !== undefined ? { clock: config.clock } : {}),
});
this.defaultChannel = config.defaultChannel;
this.clock = config.clock ?? defaultClock;
for (const ch of config.channels) {
this.channelStates.set(ch.channel, {
config: ch,
sentCount: 0,
windowStart: this.clock(),
});
}
if (config.templates !== undefined) {
for (const tpl of config.templates) {
this.templates.set(tpl.id, tpl);
}
}
if (config.routingRules !== undefined) {
this.routingRules.push(...config.routingRules);
}
if (config.deliveryChannels !== undefined) {
for (const dc of config.deliveryChannels) {
this.deliveryChannels.set(dc.name, dc);
}
}
}
/** Subscribe to a router-emitted lifecycle event. */
public on<K extends keyof NotificationEventMap>(
type: K,
listener: (payload: NotificationEventMap[K]) => void
): () => void {
return this.bus.on(type, listener);
}
/** Send a notification end-to-end. */
public async send(input: NotificationInput): Promise<NotificationResult> {
const start = this.clock();
let channel = this.resolveChannel(input);
const hookResult = await notificationHooks.applyHooksAsync(
"notification:routing",
{
input,
resolvedChannel: channel,
metadata: {},
}
);
channel = hookResult.resolvedChannel;
const { body, subject } = this.resolveContent(hookResult.input);
const priority = hookResult.input.priority ?? "normal";
try {
this.checkChannel(channel);
this.checkRateLimit(channel);
} catch (err) {
recordRouted(channel, "failed", this.clock() - start);
throw err;
}
const id = `notif_${this.nextId++}`;
const now = new Date(this.clock()).toISOString();
const baseNotification: Notification = {
id,
channel,
recipient: input.recipient,
...(subject !== undefined ? { subject } : {}),
body,
priority,
status: "sent",
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
createdAt: now,
sentAt: now,
};
this.notifications.set(id, baseNotification);
this.incrementRateCounter(channel);
const dc = this.deliveryChannels.get(channel);
if (dc !== undefined) {
const deliveryResult = await dc.send(baseNotification);
if (!deliveryResult.success) {
const failed: Notification = {
...baseNotification,
status: "failed",
};
this.notifications.set(id, failed);
this.bus.emit("notification.failed", failed);
recordDeliveryAttempt(channel, "failed");
recordRouted(channel, "failed", this.clock() - start);
const errResult: NotificationResult = {
id,
channel,
status: "failed",
...(deliveryResult.error !== undefined
? { error: deliveryResult.error }
: {}),
};
return errResult;
}
recordDeliveryAttempt(channel, "ok");
}
this.bus.emit("notification.sent", baseNotification);
recordRouted(channel, "ok", this.clock() - start);
return { id, channel, status: "sent", sentAt: now };
}
/** Enqueue a `pending` notification without delivering. */
public async enqueue(
input: NotificationInput
): Promise<NotificationResult> {
const channel = this.resolveChannel(input);
const { body, subject } = this.resolveContent(input);
const priority = input.priority ?? "normal";
this.checkChannel(channel);
const id = `notif_${this.nextId++}`;
const now = new Date(this.clock()).toISOString();
const notification: Notification = {
id,
channel,
recipient: input.recipient,
...(subject !== undefined ? { subject } : {}),
body,
priority,
status: "pending",
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
createdAt: now,
};
this.notifications.set(id, notification);
this.bus.emit("notification.created", notification);
return { id, channel, status: "pending" };
}
/** Send a batch sequentially, returning a result per input. */
public async sendBatch(
inputs: readonly NotificationInput[]
): Promise<NotificationResult[]> {
const results: NotificationResult[] = [];
for (const input of inputs) {
results.push(await this.send(input));
}
return results;
}
/** Retrieve a notification by id. */
public async getNotification(id: string): Promise<Notification> {
const notif = this.notifications.get(id);
if (notif === undefined) {
throw new NotificationNotFoundError({
context: { notificationId: id },
});
}
return notif;
}
/** Cancel a pending notification (rejects when transition is illegal). */
public async cancel(id: string): Promise<Notification> {
const notif = this.notifications.get(id);
if (notif === undefined) {
throw new NotificationNotFoundError({
context: { notificationId: id },
});
}
const validTargets = VALID_TRANSITIONS[notif.status];
if (!validTargets.includes("cancelled")) {
throw new RoutingFailedError({
context: { notificationId: id, fromStatus: notif.status },
});
}
const updated: Notification = { ...notif, status: "cancelled" };
this.notifications.set(id, updated);
this.bus.emit("notification.cancelled", updated);
return updated;
}
/** Snapshot of per-channel stats for observability. */
public async getChannelStats(): Promise<
Record<NotificationChannel, { sent: number; enabled: boolean }>
> {
const stats = {} as Record<
NotificationChannel,
{ sent: number; enabled: boolean }
>;
for (const [channel, state] of this.channelStates) {
stats[channel] = {
sent: state.sentCount,
enabled: state.config.enabled,
};
}
return stats;
}
/** Register or replace a channel config at runtime. */
public async registerChannel(config: ChannelConfig): Promise<void> {
this.channelStates.set(config.channel, {
config,
sentCount: 0,
windowStart: this.clock(),
});
}
/** Register or replace a template at runtime. */
public async registerTemplate(
template: NotificationTemplate
): Promise<void> {
this.templates.set(template.id, template);
}
/** Append a routing rule at runtime. */
public async addRoutingRule(rule: RoutingRule): Promise<void> {
this.routingRules.push(rule);
}
/** Register a delivery-channel implementation. */
public registerDeliveryChannel(channel: DeliveryChannel): void {
this.deliveryChannels.set(channel.name, channel);
}
private resolveChannel(input: NotificationInput): NotificationChannel {
if (input.channel !== undefined) return input.channel;
for (const rule of this.routingRules) {
if (
rule.priority !== undefined &&
input.priority !== undefined &&
rule.priority !== input.priority
) {
continue;
}
if (
rule.condition !== undefined &&
!rule.condition(input.recipient)
) {
continue;
}
if (rule.channels.length > 0) {
// length > 0 ⇒ index 0 is defined; the `as` cast keeps
// `noUncheckedIndexedAccess` happy without an unreachable
// guard branch.
return rule.channels[0] as NotificationChannel;
}
}
return this.defaultChannel;
}
private resolveContent(input: NotificationInput): {
body: string;
subject?: string;
} {
if (input.templateId !== undefined) {
const tpl = this.templates.get(input.templateId);
if (tpl === undefined) {
throw new TemplateNotFoundError({
context: { templateId: input.templateId },
});
}
let body = tpl.bodyTemplate;
let subject = tpl.subject;
const data = input.templateData ?? {};
for (const [key, value] of Object.entries(data)) {
const pattern = `{{${key}}}`;
body = body.split(pattern).join(value);
if (subject !== undefined) {
subject = subject.split(pattern).join(value);
}
}
const resolvedSubject = input.subject ?? subject;
return resolvedSubject !== undefined
? { body, subject: resolvedSubject }
: { body };
}
return input.subject !== undefined
? { body: input.body, subject: input.subject }
: { body: input.body };
}
private checkChannel(channel: NotificationChannel): void {
const state = this.channelStates.get(channel);
if (state === undefined || !state.config.enabled) {
throw new ChannelDisabledError({ context: { channel } });
}
}
private checkRateLimit(channel: NotificationChannel): void {
const state = this.channelStates.get(channel);
if (state === undefined || state.config.rateLimit === undefined) return;
const now = this.clock();
const windowMs = 60_000;
if (now - state.windowStart > windowMs) {
state.sentCount = 0;
state.windowStart = now;
}
if (state.sentCount >= state.config.rateLimit) {
throw new RateLimitExceededError({
context: { channel, scope: "router" },
});
}
}
private incrementRateCounter(channel: NotificationChannel): void {
// `checkChannel` already proved `state` is non-undefined; cast
// rather than guard so coverage does not see an unreachable
// branch.
const state = this.channelStates.get(channel) as ChannelState;
state.sentCount++;
}
}

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,219 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import { NotificationScheduler } from "./notification-scheduler.js";
describe("NotificationScheduler", () => {
let scheduler: NotificationScheduler | undefined;
afterEach(() => {
scheduler?.stop();
});
describe("schedule", () => {
it("schedules a notification for future delivery", {
timeout: 5000,
}, () => {
scheduler = new NotificationScheduler();
const future = new Date(Date.now() + 60_000);
const entry = scheduler.schedule(
{ recipient: "user@test.com", body: "Hello" },
future
);
expect(entry.id).toMatch(/^sched_/);
expect(entry.scheduledFor).toBe(future.getTime());
expect(entry.cancelled).toBe(false);
});
it("assigns unique ids", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const future = new Date(Date.now() + 60_000);
const e1 = scheduler.schedule(
{ recipient: "a", body: "A" },
future
);
const e2 = scheduler.schedule(
{ recipient: "b", body: "B" },
future
);
expect(e1.id).not.toBe(e2.id);
});
it("tracks size", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const future = new Date(Date.now() + 60_000);
scheduler.schedule({ recipient: "a", body: "A" }, future);
scheduler.schedule({ recipient: "b", body: "B" }, future);
expect(scheduler.size).toBe(2);
});
});
describe("cancel", () => {
it("cancels a scheduled notification", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const entry = scheduler.schedule(
{ recipient: "a", body: "A" },
new Date(Date.now() + 60_000)
);
const cancelled = scheduler.cancel(entry.id);
expect(cancelled.cancelled).toBe(true);
});
it("throws for non-existent id", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
expect(() => scheduler?.cancel("fake")).toThrow(
NotificationChannelError
);
});
});
describe("get", () => {
it("retrieves a scheduled notification", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const entry = scheduler.schedule(
{ recipient: "a", body: "A" },
new Date(Date.now() + 60_000)
);
const retrieved = scheduler.get(entry.id);
expect(retrieved.id).toBe(entry.id);
});
it("throws for non-existent id", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
expect(() => scheduler?.get("fake")).toThrow(
NotificationChannelError
);
});
});
describe("listPending", () => {
it("returns non-cancelled entries", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const future = new Date(Date.now() + 60_000);
scheduler.schedule({ recipient: "a", body: "A" }, future);
const e2 = scheduler.schedule(
{ recipient: "b", body: "B" },
future
);
scheduler.cancel(e2.id);
const pending = scheduler.listPending();
expect(pending).toHaveLength(1);
});
it("returns empty array when none pending", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
expect(scheduler.listPending()).toHaveLength(0);
});
});
describe("getDue", () => {
it("returns entries scheduled before now", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const past = new Date(Date.now() - 1_000);
const future = new Date(Date.now() + 60_000);
scheduler.schedule({ recipient: "a", body: "Past" }, past);
scheduler.schedule({ recipient: "b", body: "Future" }, future);
const due = scheduler.getDue();
expect(due).toHaveLength(1);
expect(due[0]?.input.body).toBe("Past");
});
it("excludes cancelled entries", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const past = new Date(Date.now() - 1_000);
const entry = scheduler.schedule(
{ recipient: "a", body: "A" },
past
);
scheduler.cancel(entry.id);
expect(scheduler.getDue()).toHaveLength(0);
});
it("accepts custom time for testing", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
const target = Date.now() + 5_000;
scheduler.schedule(
{ recipient: "a", body: "A" },
new Date(target - 1)
);
expect(scheduler.getDue(target)).toHaveLength(1);
expect(scheduler.getDue(target - 2)).toHaveLength(0);
});
});
describe("processDue", () => {
it("processes due notifications and removes them", {
timeout: 5000,
}, async () => {
scheduler = new NotificationScheduler();
const past = new Date(Date.now() - 1_000);
const processed: string[] = [];
scheduler.onDue(async (input) => {
processed.push(input.body);
});
scheduler.schedule({ recipient: "a", body: "Due1" }, past);
scheduler.schedule({ recipient: "b", body: "Due2" }, past);
const count = await scheduler.processDue();
expect(count).toBe(2);
expect(processed).toEqual(["Due1", "Due2"]);
expect(scheduler.size).toBe(0);
});
it("does not process future notifications", {
timeout: 5000,
}, async () => {
scheduler = new NotificationScheduler();
const future = new Date(Date.now() + 60_000);
scheduler.onDue(async () => {
/* noop */
});
scheduler.schedule({ recipient: "a", body: "Future" }, future);
const count = await scheduler.processDue();
expect(count).toBe(0);
expect(scheduler.size).toBe(1);
});
it("works without a handler (drops due entries)", {
timeout: 5000,
}, async () => {
scheduler = new NotificationScheduler();
const past = new Date(Date.now() - 1_000);
scheduler.schedule({ recipient: "a", body: "A" }, past);
const count = await scheduler.processDue();
expect(count).toBe(0);
expect(scheduler.size).toBe(0);
});
});
describe("start/stop", () => {
it("start is idempotent", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler(10_000);
scheduler.start();
scheduler.start();
scheduler.stop();
});
it("stop is idempotent", { timeout: 5000 }, () => {
scheduler = new NotificationScheduler();
scheduler.stop();
scheduler.stop();
});
it("start triggers interval-based processing", {
timeout: 5000,
}, async () => {
// Use a tiny interval and a past-due entry; the interval tick
// is what we care about — the entry is removed after one tick.
scheduler = new NotificationScheduler(1);
scheduler.schedule(
{ recipient: "a", body: "A" },
new Date(Date.now() - 1)
);
scheduler.onDue(async () => {
/* noop */
});
scheduler.start();
await new Promise((r) => setTimeout(r, 25));
expect(scheduler.size).toBe(0);
});
});
});

View File

@@ -0,0 +1,132 @@
import type { Clock } from "@suaveplan/types";
import { NotificationNotFoundError } from "../error-codes/error-codes.js";
import type { NotificationInput } from "../types/notification.types.js";
/** A scheduled notification entry. */
export interface ScheduledNotification {
readonly id: string;
readonly input: NotificationInput;
readonly scheduledFor: number;
readonly createdAt: string;
cancelled: boolean;
}
/** Callback invoked when a scheduled notification is due. */
export type ScheduleHandler = (input: NotificationInput) => Promise<void>;
const defaultClock: Clock = () => Date.now();
/**
* Schedules notifications for future delivery. The scheduler does not
* fire the actual send — callers register an `onDue` handler that
* receives the original `NotificationInput`, typically wiring it back
* into `NotificationRouter.send` or `enqueue`.
*/
export class NotificationScheduler {
private readonly scheduled = new Map<string, ScheduledNotification>();
private nextId = 1;
private handler?: ScheduleHandler;
private intervalId: ReturnType<typeof setInterval> | undefined = undefined;
private readonly checkIntervalMs: number;
private readonly clock: Clock;
public constructor(checkIntervalMs = 1_000, clock: Clock = defaultClock) {
this.checkIntervalMs = checkIntervalMs;
this.clock = clock;
}
/** Register the handler invoked for each due entry. */
public onDue(handler: ScheduleHandler): void {
this.handler = handler;
}
/** Schedule a notification for future delivery. */
public schedule(
input: NotificationInput,
deliverAt: Date
): ScheduledNotification {
const id = `sched_${this.nextId++}`;
const entry: ScheduledNotification = {
id,
input,
scheduledFor: deliverAt.getTime(),
createdAt: new Date(this.clock()).toISOString(),
cancelled: false,
};
this.scheduled.set(id, entry);
return entry;
}
/** Cancel a scheduled notification (throws when id is unknown). */
public cancel(id: string): ScheduledNotification {
const entry = this.scheduled.get(id);
if (entry === undefined) {
throw new NotificationNotFoundError({
context: { notificationId: id },
});
}
entry.cancelled = true;
return entry;
}
/** Retrieve a scheduled notification (throws when id is unknown). */
public get(id: string): ScheduledNotification {
const entry = this.scheduled.get(id);
if (entry === undefined) {
throw new NotificationNotFoundError({
context: { notificationId: id },
});
}
return entry;
}
/** List every pending (non-cancelled) scheduled notification. */
public listPending(): ScheduledNotification[] {
return [...this.scheduled.values()].filter((e) => !e.cancelled);
}
/** Notifications whose `scheduledFor <= now`. */
public getDue(now?: number): ScheduledNotification[] {
const currentTime = now ?? this.clock();
return [...this.scheduled.values()].filter(
(e) => !e.cancelled && e.scheduledFor <= currentTime
);
}
/** Process every due entry through the handler and remove them. */
public async processDue(): Promise<number> {
const due = this.getDue();
let processed = 0;
for (const entry of due) {
if (this.handler !== undefined && !entry.cancelled) {
await this.handler(entry.input);
processed++;
}
this.scheduled.delete(entry.id);
}
return processed;
}
/** Start automatic interval-based processing. */
public start(): void {
if (this.intervalId !== undefined) return;
this.intervalId = setInterval(() => {
void this.processDue();
}, this.checkIntervalMs);
}
/** Stop automatic processing (idempotent). */
public stop(): void {
if (this.intervalId !== undefined) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
/** Total number of scheduled (including cancelled) entries. */
public get size(): number {
return this.scheduled.size;
}
}

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,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ChannelConfigSchema,
DigestChannelConfigSchema,
EmailChannelConfigSchema,
NotificationChannelSchema,
NotificationInputSchema,
NotificationPrioritySchema,
NotificationRouterConfigSchema,
NotificationStatusSchema,
NotificationTemplateSchema,
PreferenceManagerOptionsSchema,
PushChannelConfigSchema,
QuietHoursConfigSchema,
RateLimitRuleSchema,
RetryConfigSchema,
RoutingRuleSchema,
SmsChannelConfigSchema,
StoredPreferencesSchema,
TemplateRegistrationSchema,
VapidKeysSchema,
WebhookChannelConfigSchema,
} from "./schemas.js";
describe("schemas", () => {
it("every public option schema parses a real example", {
timeout: 5000,
}, () => {
expect(NotificationChannelSchema.parse("email")).toBe("email");
expect(NotificationPrioritySchema.parse("urgent")).toBe("urgent");
expect(NotificationStatusSchema.parse("sent")).toBe("sent");
expect(
RateLimitRuleSchema.parse({ maxCount: 5, windowMs: 60_000 })
).toEqual({ maxCount: 5, windowMs: 60_000 });
expect(
ChannelConfigSchema.parse({
channel: "email",
enabled: true,
rateLimit: 10,
})
).toMatchObject({ channel: "email", enabled: true, rateLimit: 10 });
expect(
RoutingRuleSchema.parse({
id: "r1",
name: "n",
channels: ["email"],
})
).toMatchObject({ id: "r1", name: "n" });
expect(
NotificationTemplateSchema.parse({
id: "t1",
name: "n",
channel: "email",
bodyTemplate: "Hi {{x}}",
defaultPriority: "normal",
})
).toMatchObject({ id: "t1", defaultPriority: "normal" });
expect(
TemplateRegistrationSchema.parse({
id: "t1",
name: "n",
defaultTemplate: { bodyTemplate: "Body" },
})
).toMatchObject({ id: "t1" });
expect(
NotificationInputSchema.parse({
recipient: "a@b.c",
body: "Hi",
})
).toMatchObject({ recipient: "a@b.c" });
expect(
NotificationRouterConfigSchema.parse({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
})
).toMatchObject({ defaultChannel: "email" });
expect(
EmailChannelConfigSchema.parse({
from: "from@example.com",
sendFn: () => undefined,
})
).toMatchObject({ from: "from@example.com" });
expect(
SmsChannelConfigSchema.parse({
from: "+1",
sendFn: () => undefined,
})
).toMatchObject({ from: "+1" });
expect(
VapidKeysSchema.parse({
publicKey: "p",
privateKey: "k",
subject: "mailto:a@b.c",
})
).toMatchObject({ publicKey: "p" });
expect(
PushChannelConfigSchema.parse({
vapid: {
publicKey: "p",
privateKey: "k",
subject: "mailto:a@b.c",
},
getSubscriptions: () => undefined,
})
).toMatchObject({});
expect(WebhookChannelConfigSchema.parse({})).toEqual({});
expect(DigestChannelConfigSchema.parse({ delegate: {} })).toMatchObject(
{}
);
expect(
RetryConfigSchema.parse({ maxRetries: 1, baseDelayMs: 100 })
).toMatchObject({ maxRetries: 1 });
expect(
QuietHoursConfigSchema.parse({ startHour: 22, endHour: 7 })
).toMatchObject({ startHour: 22 });
expect(
StoredPreferencesSchema.parse({
userId: "u",
enabledChannels: ["email"],
disabledChannels: [],
})
).toMatchObject({ userId: "u" });
expect(
PreferenceManagerOptionsSchema.parse({ store: {} })
).toMatchObject({});
});
it("router config schema rejects malformed channels", {
timeout: 5000,
}, () => {
expect(() =>
NotificationRouterConfigSchema.parse({
channels: "not-an-array",
defaultChannel: "email",
})
).toThrow();
expect(() =>
NotificationRouterConfigSchema.parse({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "carrier-pigeon",
})
).toThrow();
});
it("input schema rejects empty recipient", { timeout: 5000 }, () => {
expect(() =>
NotificationInputSchema.parse({ recipient: "", body: "x" })
).toThrow();
});
it("quiet-hours schema rejects out-of-range hours", {
timeout: 5000,
}, () => {
expect(() =>
QuietHoursConfigSchema.parse({ startHour: 25, endHour: 7 })
).toThrow();
});
});

View File

@@ -0,0 +1,177 @@
import z from "zod";
/**
* Zod schemas for every public option bundle. Callers may invoke
* `.parse(...)` to validate untrusted input before passing it into
* any constructor or public method.
*
* The schemas describe shape only — function-valued fields like
* `sendFn`, `fetchFn`, `condition`, and `getSubscriptions` are typed
* as `z.unknown()` so Zod does not attempt to introspect callable
* values; the TypeScript surface remains the source of truth for
* callable shapes.
*/
export const NotificationChannelSchema = z.enum([
"email",
"sms",
"push",
"in_app",
"webhook",
]);
export const NotificationPrioritySchema = z.enum([
"low",
"normal",
"high",
"urgent",
]);
export const NotificationStatusSchema = z.enum([
"pending",
"sending",
"sent",
"delivered",
"failed",
"cancelled",
]);
export const RateLimitRuleSchema = z.object({
maxCount: z.number().int().nonnegative(),
windowMs: z.number().int().positive(),
});
export const ChannelConfigSchema = z.object({
channel: NotificationChannelSchema,
enabled: z.boolean(),
rateLimit: z.number().int().nonnegative().optional(),
retryPolicy: z
.object({
maxRetries: z.number().int().nonnegative(),
backoffMs: z.number().int().nonnegative(),
})
.optional(),
});
export const RoutingRuleSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
channels: z.array(NotificationChannelSchema),
priority: NotificationPrioritySchema.optional(),
condition: z.unknown().optional(),
});
export const NotificationTemplateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
channel: NotificationChannelSchema,
subject: z.string().optional(),
bodyTemplate: z.string(),
defaultPriority: NotificationPrioritySchema,
});
export const TemplateRegistrationSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
defaultTemplate: z.object({
subject: z.string().optional(),
bodyTemplate: z.string(),
}),
channelTemplates: z
.record(
NotificationChannelSchema,
z.object({
subject: z.string().optional(),
bodyTemplate: z.string(),
})
)
.optional(),
});
export const NotificationInputSchema = z.object({
recipient: z.string().min(1),
channel: NotificationChannelSchema.optional(),
subject: z.string().optional(),
body: z.string(),
priority: NotificationPrioritySchema.optional(),
templateId: z.string().optional(),
templateData: z.record(z.string(), z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const NotificationRouterConfigSchema = z.object({
channels: z.array(ChannelConfigSchema),
templates: z.array(NotificationTemplateSchema).optional(),
routingRules: z.array(RoutingRuleSchema).optional(),
defaultChannel: NotificationChannelSchema,
deliveryChannels: z.array(z.unknown()).optional(),
clock: z.unknown().optional(),
});
export const EmailChannelConfigSchema = z.object({
from: z.string().min(1),
sendFn: z.unknown(),
clock: z.unknown().optional(),
});
export const SmsChannelConfigSchema = z.object({
from: z.string().min(1),
sendFn: z.unknown(),
clock: z.unknown().optional(),
});
export const VapidKeysSchema = z.object({
publicKey: z.string().min(1),
privateKey: z.string().min(1),
subject: z.string().min(1),
});
export const PushChannelConfigSchema = z.object({
vapid: VapidKeysSchema,
getSubscriptions: z.unknown(),
sendFn: z.unknown().optional(),
fetchFn: z.unknown().optional(),
ttl: z.number().int().positive().optional(),
clock: z.unknown().optional(),
});
export const WebhookChannelConfigSchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
fetchFn: z.unknown().optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z.unknown().optional(),
});
export const DigestChannelConfigSchema = z.object({
delegate: z.unknown(),
intervalMs: z.number().int().nonnegative().optional(),
maxPerDigest: z.number().int().positive().optional(),
formatDigest: z.unknown().optional(),
clock: z.unknown().optional(),
});
export const RetryConfigSchema = z.object({
maxRetries: z.number().int().nonnegative(),
baseDelayMs: z.number().int().nonnegative(),
maxDelayMs: z.number().int().positive().optional(),
});
export const QuietHoursConfigSchema = z.object({
startHour: z.number().int().min(0).max(23),
endHour: z.number().int().min(0).max(23),
days: z.array(z.number().int().min(0).max(6)).optional(),
});
export const StoredPreferencesSchema = z.object({
userId: z.string().min(1),
enabledChannels: z.array(NotificationChannelSchema),
disabledChannels: z.array(NotificationChannelSchema),
quietHours: QuietHoursConfigSchema.optional(),
urgentThreshold: NotificationPrioritySchema.optional(),
});
export const PreferenceManagerOptionsSchema = z.object({
store: z.unknown(),
defaultChannels: z.array(NotificationChannelSchema).optional(),
clock: z.unknown().optional(),
});

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.

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NotificationChannelError } from "../error-codes/error-codes.js";
import { TemplateEngine } from "./template-engine.js";
describe("TemplateEngine", () => {
describe("register and lookup", () => {
it("registers a template", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Welcome",
defaultTemplate: { bodyTemplate: "Hello {{name}}" },
});
expect(engine.has("tpl_1")).toBe(true);
});
it("reports false for unregistered template", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
expect(engine.has("nonexistent")).toBe(false);
});
it("lists registered template ids", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "A",
defaultTemplate: { bodyTemplate: "A" },
});
engine.register({
id: "tpl_2",
name: "B",
defaultTemplate: { bodyTemplate: "B" },
});
expect(engine.listIds()).toEqual(["tpl_1", "tpl_2"]);
});
it("unregisters a template", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "A",
defaultTemplate: { bodyTemplate: "A" },
});
expect(engine.unregister("tpl_1")).toBe(true);
expect(engine.has("tpl_1")).toBe(false);
});
it("returns false when unregistering non-existent", {
timeout: 5000,
}, () => {
const engine = new TemplateEngine();
expect(engine.unregister("fake")).toBe(false);
});
});
describe("render", () => {
it("renders with variable substitution", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Greet",
defaultTemplate: {
subject: "Hello {{name}}",
bodyTemplate: "Welcome, {{name}}! Your code is {{code}}.",
},
});
const result = engine.render("tpl_1", {
name: "Alice",
code: "1234",
});
expect(result.body).toBe("Welcome, Alice! Your code is 1234.");
expect(result.subject).toBe("Hello Alice");
});
it("renders without data", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Static",
defaultTemplate: { bodyTemplate: "No variables here" },
});
const result = engine.render("tpl_1");
expect(result.body).toBe("No variables here");
});
it("throws for non-existent template", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
expect(() => engine.render("fake")).toThrow(
NotificationChannelError
);
});
it("leaves unreplaced variables intact", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Partial",
defaultTemplate: {
bodyTemplate: "Hi {{name}}, code: {{code}}",
},
});
const result = engine.render("tpl_1", { name: "Bob" });
expect(result.body).toBe("Hi Bob, code: {{code}}");
});
it("returns undefined subject when not set", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "NoSubject",
defaultTemplate: { bodyTemplate: "Body only" },
});
const result = engine.render("tpl_1");
expect(result.subject).toBeUndefined();
});
});
describe("channel-specific templates", () => {
it("uses channel-specific template when available", {
timeout: 5000,
}, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Multi",
defaultTemplate: {
subject: "Default Subject",
bodyTemplate: "Default body for {{name}}",
},
channelTemplates: {
sms: { bodyTemplate: "SMS: Hi {{name}}" },
email: {
subject: "Email Subject for {{name}}",
bodyTemplate: "<h1>Hello {{name}}</h1>",
},
},
});
const smsResult = engine.render("tpl_1", { name: "Alice" }, "sms");
expect(smsResult.body).toBe("SMS: Hi Alice");
expect(smsResult.subject).toBeUndefined();
const emailResult = engine.render(
"tpl_1",
{ name: "Alice" },
"email"
);
expect(emailResult.body).toBe("<h1>Hello Alice</h1>");
expect(emailResult.subject).toBe("Email Subject for Alice");
});
it("falls back to default when channel variant missing", {
timeout: 5000,
}, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "Fallback",
defaultTemplate: { bodyTemplate: "Default body" },
channelTemplates: {
email: { bodyTemplate: "Email body" },
},
});
const result = engine.render("tpl_1", undefined, "sms");
expect(result.body).toBe("Default body");
});
it("uses default when no channel specified", { timeout: 5000 }, () => {
const engine = new TemplateEngine();
engine.register({
id: "tpl_1",
name: "NoChannel",
defaultTemplate: { bodyTemplate: "Default" },
channelTemplates: { email: { bodyTemplate: "Email" } },
});
const result = engine.render("tpl_1");
expect(result.body).toBe("Default");
});
});
});

View File

@@ -0,0 +1,104 @@
import { TemplateNotFoundError } from "../error-codes/error-codes.js";
import type { NotificationChannel } from "../types/notification.types.js";
/** Channel-specific template variant. */
export interface ChannelTemplateBody {
readonly subject?: string;
readonly bodyTemplate: string;
}
/** Template registration entry supporting channel-specific variants. */
export interface TemplateRegistration {
readonly id: string;
readonly name: string;
/** Default template used when no channel-specific variant exists. */
readonly defaultTemplate: ChannelTemplateBody;
/** Channel-specific template overrides. */
readonly channelTemplates?: Partial<
Record<NotificationChannel, ChannelTemplateBody>
>;
}
/** A registered template projected for a specific channel. */
export interface ChannelTemplate {
readonly id: string;
readonly name: string;
readonly subject?: string;
readonly bodyTemplate: string;
readonly channel?: NotificationChannel;
}
/** Result of rendering a template. */
export interface RenderedTemplate {
readonly subject?: string;
readonly body: string;
}
/**
* Manages notification templates with `{{var}}` substitution and
* channel-specific variants. Variables that have no value in `data`
* are left intact so callers can detect missing substitutions during
* QA without the template silently producing nonsense.
*/
export class TemplateEngine {
private readonly templates = new Map<string, TemplateRegistration>();
/** Register a template (overrides any existing entry with the same id). */
public register(template: TemplateRegistration): void {
this.templates.set(template.id, template);
}
/** Remove a registered template. */
public unregister(id: string): boolean {
return this.templates.delete(id);
}
/** Check whether a template exists. */
public has(id: string): boolean {
return this.templates.has(id);
}
/** List every registered template id. */
public listIds(): string[] {
return [...this.templates.keys()];
}
/**
* Render a template with variable substitution. Uses the
* channel-specific variant when available, otherwise the default.
*/
public render(
templateId: string,
data?: Readonly<Record<string, string>>,
channel?: NotificationChannel
): RenderedTemplate {
const registration = this.templates.get(templateId);
if (registration === undefined) {
throw new TemplateNotFoundError({
context: { templateId },
});
}
const variantOverride =
channel !== undefined
? registration.channelTemplates?.[channel]
: undefined;
const variant: ChannelTemplateBody =
variantOverride ?? registration.defaultTemplate;
let body = variant.bodyTemplate;
let subject = variant.subject;
if (data !== undefined) {
for (const [key, value] of Object.entries(data)) {
const pattern = `{{${key}}}`;
body = body.split(pattern).join(value);
if (subject !== undefined) {
subject = subject.split(pattern).join(value);
}
}
}
return subject !== undefined ? { subject, body } : { body };
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VALID_TRANSITIONS } from "./notification.types.js";
describe("VALID_TRANSITIONS", () => {
it("allows pending to sending", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.pending).toContain("sending");
});
it("allows pending to cancelled", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.pending).toContain("cancelled");
});
it("allows sending to sent", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.sending).toContain("sent");
});
it("allows sending to failed", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.sending).toContain("failed");
});
it("allows sent to delivered", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.sent).toContain("delivered");
});
it("allows sent to failed (e.g. async bounce)", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.sent).toContain("failed");
});
it("has no transitions from delivered", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.delivered).toHaveLength(0);
});
it("has no transitions from failed", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.failed).toHaveLength(0);
});
it("has no transitions from cancelled", { timeout: 5000 }, () => {
expect(VALID_TRANSITIONS.cancelled).toHaveLength(0);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(VALID_TRANSITIONS)).toBe(true);
});
});

View File

@@ -0,0 +1,109 @@
/** Supported notification delivery channels. */
export type NotificationChannel =
| "email"
| "sms"
| "push"
| "in_app"
| "webhook";
/** Priority levels for notifications. */
export type NotificationPriority = "low" | "normal" | "high" | "urgent";
/** Notification delivery status. */
export type NotificationStatus =
| "pending"
| "sending"
| "sent"
| "delivered"
| "failed"
| "cancelled";
/** A notification record. */
export interface Notification {
readonly id: string;
readonly channel: NotificationChannel;
readonly recipient: string;
readonly subject?: string;
readonly body: string;
readonly priority: NotificationPriority;
readonly status: NotificationStatus;
readonly metadata?: Record<string, unknown>;
readonly createdAt: string;
readonly sentAt?: string;
readonly deliveredAt?: string;
}
/** A notification template with variable substitution. */
export interface NotificationTemplate {
readonly id: string;
readonly name: string;
readonly channel: NotificationChannel;
readonly subject?: string;
readonly bodyTemplate: string;
readonly defaultPriority: NotificationPriority;
}
/** Per-channel retry policy. */
export interface ChannelRetryPolicy {
readonly maxRetries: number;
readonly backoffMs: number;
}
/** Channel configuration with rate limiting and retry policy. */
export interface ChannelConfig {
readonly channel: NotificationChannel;
readonly enabled: boolean;
readonly rateLimit?: number;
readonly retryPolicy?: ChannelRetryPolicy;
}
/** A routing rule that directs notifications to channels. */
export interface RoutingRule {
readonly id: string;
readonly name: string;
readonly channels: readonly NotificationChannel[];
readonly priority?: NotificationPriority;
readonly condition?: (recipient: string) => boolean;
}
/** Input for sending a notification. */
export interface NotificationInput {
readonly recipient: string;
readonly channel?: NotificationChannel;
readonly subject?: string;
readonly body: string;
readonly priority?: NotificationPriority;
readonly templateId?: string;
readonly templateData?: Record<string, string>;
readonly metadata?: Record<string, unknown>;
}
/** Result of sending a notification. */
export interface NotificationResult {
readonly id: string;
readonly channel: NotificationChannel;
readonly status: NotificationStatus;
readonly sentAt?: string;
readonly error?: string;
}
/** Bus event payloads emitted by the NotificationRouter. */
export type NotificationEventMap = {
"notification.created": Notification;
"notification.sent": Notification;
"notification.delivered": Notification;
"notification.failed": Notification;
"notification.cancelled": Notification;
} & Record<string, unknown>;
/** Valid notification status transitions. */
export const VALID_TRANSITIONS: Readonly<
Record<NotificationStatus, readonly NotificationStatus[]>
> = Object.freeze({
pending: ["sending", "cancelled"],
sending: ["sent", "failed"],
sent: ["delivered", "failed"],
delivered: [],
failed: [],
cancelled: [],
});

View File

@@ -0,0 +1,38 @@
/**
* Test-environment setup for @suaveplan/notification-channel.
*
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
* module loads so the meter resolved at module load is the real provider's
* meter (and not the no-op fallback). Tests that inspect emitted instruments
* read them out of `testInMemoryMetricExporter` via `flushTestMetrics()`.
*
* Universal: no `node:fs`, no `process.cwd()`, no DOM globals.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics(): Promise<
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
> {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/notification-channel",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});