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,19 @@
# `messaging`
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
<!-- begin:auto-docs -->
## Packages in `messaging`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/discord-client`](./discord-client/README.md) | Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure… |
| [`@suaveplan/imessage-adapter`](./imessage-adapter/README.md) | macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter` implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes Apple-epoch / nanosecond t… |
| [`@suaveplan/notification-channel`](./notification-channel/README.md) | Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentati… |
| [`@suaveplan/slack-client`](./slack-client/README.md) | Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock… |
| [`@suaveplan/sms`](./sms/README.md) | Universal SMS core: provider-agnostic send pipeline with strict E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limit… |
| [`@suaveplan/sms-providers`](./sms-providers/README.md) | Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam. |
| [`@suaveplan/telegram-client`](./telegram-client/README.md) | Telegram Bot API client: send/edit/delete messages, six media variants, inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and OpenTelemetr… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.

View File

@@ -0,0 +1,100 @@
# @suaveplan/discord-client - Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes
Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure mode to a stable `DISCORD.*` error code and emits OpenTelemetry counters and histograms for every request.
<!-- begin:auto-docs -->
## Documentation tree
- [bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [discord-bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [discord-webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [errors](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [discord-client-error](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [http](./src/utils/http.md) — Single funnel through which every Discord API and webhook request flows. It maps Discord response codes onto the `Disco…
- [rate-limit](./src/utils/rate-limit.md) — Parses Discord's three rate-limit response headers into a single millisecond back-off duration that callers can use to…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/discord-client
```
## Quick Start
```ts
import { DiscordBotClient, DiscordWebhook } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello from the bot" });
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("hello from a webhook");
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Bot Client | [src/core/bot-client/discord-bot-client.md](src/core/bot-client/discord-bot-client.md) | `DiscordBotClient` — send/edit/delete messages, reactions, channels, guilds |
| Webhook | [src/core/webhook/discord-webhook.md](src/core/webhook/discord-webhook.md) | `DiscordWebhook` — webhook-URL delivery with structured reports |
| HTTP Helper | [src/utils/http.md](src/utils/http.md) | Shared request funnel: error mapping, timeout, OTel metrics |
| Rate-Limit | [src/utils/rate-limit.md](src/utils/rate-limit.md) | `parseRetryAfterMs` parser for Discord's three rate-limit headers |
| Errors | [src/errors/discord-client-error.md](src/errors/discord-client-error.md) | `DISCORD_CODES` registry and `DiscordClientError` |
| Types | [src/types/discord-client.types.md](src/types/discord-client.types.md) | Public type surface + Zod boundary schemas |
## Subpath Exports
```ts
import { DiscordBotClient } from "@suaveplan/discord-client/bot";
import { DiscordWebhook } from "@suaveplan/discord-client/webhook";
import { DISCORD_CODES } from "@suaveplan/discord-client/errors";
import { DiscordMessageSchema } from "@suaveplan/discord-client/types";
```
## Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `DISCORD.API_ERROR` | 502 | Generic non-2xx bot response |
| `DISCORD.AUTH_ERROR` | 401 | Token rejected |
| `DISCORD.RATE_LIMITED` | 429 | Honour `error.context.retryAfterMs` |
| `DISCORD.CHANNEL_NOT_FOUND` | 404 | Channel missing or bot lacks access |
| `DISCORD.GUILD_NOT_FOUND` | 404 | Guild missing or bot is not a member |
| `DISCORD.CONFIG_MISSING` | 500 | Required config value missing |
| `DISCORD.INVALID_CONFIG` | 500 | Config failed Zod validation |
| `DISCORD.INVALID_MESSAGE` | 400 | Message payload failed Zod validation |
| `DISCORD.WEBHOOK_FAILED` | 502 | Webhook delivery rejected |
| `DISCORD.TIMEOUT` | 504 | Request aborted by timeout |
| `DISCORD.FORBIDDEN` | 403 | Caller lacks permissions |
## Injectable Fetch & Clock
Both `DiscordBotClient` and `DiscordWebhook` accept `fetch` and `clock` injectables — pass deterministic stubs in tests, omit them in production to use `globalThis.fetch` and `Date.now`.
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const stubFetch: FetchLike = async () => ({
ok: true, status: 200, statusText: "OK",
headers: { get: () => null },
text: async () => "", json: async () => ({ id: "1", channelId: "1", content: "x", timestamp: "" }),
});
new DiscordBotClient({
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
});
```
## OTel Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `discord_client.request.total` | Counter | `kind`, `method`, `outcome`, `status` |
| `discord_client.request.duration_ms` | Histogram | `kind`, `method`, `outcome` |

View File

@@ -0,0 +1,86 @@
{
"name": "@suaveplan/discord-client",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes",
"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"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bot": {
"import": {
"types": "./dist/core/bot-client/discord-bot-client.d.ts",
"default": "./dist/core/bot-client/discord-bot-client.js"
}
},
"./webhook": {
"import": {
"types": "./dist/core/webhook/discord-webhook.d.ts",
"default": "./dist/core/webhook/discord-webhook.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/discord-client-error.d.ts",
"default": "./dist/errors/discord-client-error.js"
}
},
"./types": {
"import": {
"types": "./dist/types/discord-client.types.d.ts",
"default": "./dist/types/discord-client.types.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": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@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,92 @@
# Discord Bot Client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the bot-token half of `@suaveplan/discord-client`. Wraps the
Discord Bot REST API (`/channels/:id/messages`, `/guilds/:id`, reactions) with
a runtime-agnostic surface: every constructor option is Zod-validated, every
network call routes through the shared `executeDiscordRequest` funnel (which
emits OTel metrics and maps responses to `DiscordClientError`), and both the
HTTP client and the wall clock are injectable so tests stay deterministic
without ever touching the real Discord API.
## Features
- `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`,
`createReaction`, `getStoredMessage`.
- In-memory message cache keyed by Discord message ID. `deleteMessage` evicts.
- Boundary validation of `DiscordMessage` via `DiscordMessageSchema`.
- Boundary validation of `DiscordBotConfig` via `DiscordBotConfigSchema`.
- Per-request `AbortController` timeout (default 10s) enforced through
`executeDiscordRequest`.
- `Bot <token>` authorization header set on every request.
- `/guilds/:id` requests automatically tag `isGuildRequest: true` so 404s
raise `DISCORD.GUILD_NOT_FOUND` instead of `DISCORD.CHANNEL_NOT_FOUND`.
- camelCase → snake_case mapping (`avatarUrl` → `avatar_url`) for Discord
payload compatibility.
## Usage: Basic
```ts
import { DiscordBotClient } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello" });
```
## Usage: Advanced
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const fakeFetch: FetchLike = async (_url, _init) => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({
id: "42",
channelId: "1",
content: "hi",
timestamp: "2026-01-01T00:00:00.000Z",
}),
});
const bot = new DiscordBotClient({
token: "abc",
fetch: fakeFetch,
clock: () => 1_700_000_000_000,
});
const sent = await bot.sendMessage("1", { content: "hi" });
expect(bot.getStoredMessage(sent.id)).toEqual(sent);
```
## API Reference
- `new DiscordBotClient(config: DiscordBotConfig)` — throws
`DiscordClientError(DISCORD.CONFIG_MISSING)` if `token` is missing,
`DiscordClientError(DISCORD.INVALID_CONFIG)` for other Zod failures.
- `sendMessage(channelId, message)` → `DiscordResponse`. Validates payload.
- `editMessage(channelId, messageId, partial)` → `DiscordResponse`.
- `deleteMessage(channelId, messageId)` → `void`.
- `getChannel(channelId)` → `DiscordChannel`.
- `getGuild(guildId)` → `{ id; name }`.
- `createReaction(channelId, messageId, emoji)` → `void`.
- `getStoredMessage(messageId)` → `DiscordResponse | undefined`.
## Implementation Notes
The in-memory `messages` cache is intentionally simple — Discord assigns
monotonic snowflake IDs so no eviction policy is required for typical usage.
Callers needing TTLs should layer their own cache around `getStoredMessage`.
`createReaction` URL-encodes the emoji segment because Discord rejects raw
non-ASCII path segments. `request<T>` deliberately funnels through
`executeDiscordRequest` so the bot client contributes zero bespoke error or
metric code — the funnel owns the entire error mapping and observability
surface.

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../../types/discord-client.types.js";
import { DiscordBotClient } from "./discord-bot-client.js";
function ok(
body: unknown,
headers: Record<string, string> = {}
): DiscordFetchResponse {
return {
ok: true,
status: 200,
statusText: "OK",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => "",
json: async () => body,
};
}
function noContent(): DiscordFetchResponse {
return {
ok: true,
status: 204,
statusText: "No Content",
headers: { get: () => null },
text: async () => "",
json: async () => ({}),
};
}
function makeFetch(
handler: (
url: string,
method: string,
body?: string
) => DiscordFetchResponse
): {
fetch: FetchLike;
calls: {
url: string;
method: string;
body?: string;
headers: Record<string, string>;
}[];
} {
const calls: {
url: string;
method: string;
body?: string;
headers: Record<string, string>;
}[] = [];
const fetchFn: FetchLike = async (url, init) => {
calls.push({
url,
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
headers: init.headers,
});
return handler(url, init.method, init.body);
};
return { fetch: fetchFn, calls };
}
describe("DiscordBotClient constructor", () => {
it("rejects missing token with DISCORD.CONFIG_MISSING", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(DiscordClientError);
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.CONFIG_MISSING
);
}
});
it("rejects invalid baseUrl with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "abc", baseUrl: "not-a-url" });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("rejects negative timeoutMs with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "abc", timeoutMs: -1 });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("defaults baseUrl and timeoutMs", { timeout: 5000 }, () => {
const c = new DiscordBotClient({ token: "abc" });
expect(c).toBeInstanceOf(DiscordBotClient);
});
});
describe("DiscordBotClient.sendMessage", () => {
it("POSTs to /channels/:id/messages and caches the response", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "42",
channelId: "1",
content: "hi",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.sendMessage("1", { content: "hi" });
expect(res.id).toBe("42");
expect(calls[0]?.method).toBe("POST");
expect(calls[0]?.url).toBe(
"https://discord.com/api/v10/channels/1/messages"
);
expect(calls[0]?.headers["Authorization"]).toBe("Bot abc");
expect(calls[0]?.headers["Content-Type"]).toBe("application/json");
expect(bot.getStoredMessage("42")).toEqual(res);
});
it("forwards every optional field including avatar/tts/embed", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "43",
channelId: "1",
content: "x",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.sendMessage("1", {
content: "x",
embeds: [{ title: "t", description: "d" }],
username: "n",
avatarUrl: "https://example.com/a.png",
tts: true,
});
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("x");
expect(body["embeds"]).toEqual([{ title: "t", description: "d" }]);
expect(body["username"]).toBe("n");
expect(body["avatar_url"]).toBe("https://example.com/a.png");
expect(body["tts"]).toBe(true);
});
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const bot = new DiscordBotClient({
token: "abc",
fetch: async () => ok({}),
});
await expect(
bot.sendMessage("1", { content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
});
describe("DiscordBotClient.editMessage / deleteMessage", () => {
it("PATCHes and caches the new payload", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "44",
channelId: "1",
content: "edited",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.editMessage("1", "44", { content: "edited" });
expect(calls[0]?.method).toBe("PATCH");
expect(calls[0]?.url).toBe(
"https://discord.com/api/v10/channels/1/messages/44"
);
expect(bot.getStoredMessage("44")?.content).toBe("edited");
expect(res.content).toBe("edited");
});
it("editMessage with only embeds (no content) skips message validation", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "46",
channelId: "1",
content: "",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.editMessage("1", "46", {
embeds: [{ title: "updated" }],
});
expect(res.id).toBe("46");
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBeUndefined();
expect(body["embeds"]).toEqual([{ title: "updated" }]);
});
it("editMessage rejects oversized content with INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const bot = new DiscordBotClient({
token: "abc",
fetch: async () => ok({}),
});
await expect(
bot.editMessage("1", "44", { content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
it("DELETEs and evicts from the cache", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch((_url, method) => {
if (method === "DELETE") return noContent();
return ok({
id: "45",
channelId: "1",
content: "x",
timestamp: "2026-01-01T00:00:00.000Z",
});
});
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.sendMessage("1", { content: "x" });
await bot.deleteMessage("1", "45");
expect(calls[1]?.method).toBe("DELETE");
expect(bot.getStoredMessage("45")).toBeUndefined();
});
});
describe("DiscordBotClient.getChannel / getGuild", () => {
it("GETs the channel", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() =>
ok({ id: "1", name: "general", type: 0 })
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const ch = await bot.getChannel("1");
expect(ch.name).toBe("general");
expect(calls[0]?.method).toBe("GET");
expect(calls[0]?.url).toBe("https://discord.com/api/v10/channels/1");
});
it("getGuild raises GUILD_NOT_FOUND on 404", {
timeout: 30000,
}, async () => {
const fetchFn: FetchLike = async () => ({
ok: false,
status: 404,
statusText: "Not Found",
headers: { get: () => null },
text: async () => "no",
json: async () => ({}),
});
const bot = new DiscordBotClient({ token: "abc", fetch: fetchFn });
await expect(bot.getGuild("999")).rejects.toMatchObject({
code: DISCORD_CODES.GUILD_NOT_FOUND,
});
});
});
describe("DiscordBotClient.createReaction", () => {
it("URL-encodes the emoji and PUTs", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() => noContent());
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.createReaction("1", "44", "👍");
expect(calls[0]?.method).toBe("PUT");
expect(calls[0]?.url).toBe(
`https://discord.com/api/v10/channels/1/messages/44/reactions/${encodeURIComponent("👍")}/@me`
);
});
});
describe("DiscordBotClient defaults branch", () => {
it("uses Date.now and globalThis.fetch when not injected", {
timeout: 30000,
}, async () => {
const originalFetch = globalThis.fetch;
const headersGet = (n: string): string | null => null;
type MinimalResponse = {
ok: boolean;
status: number;
statusText: string;
headers: { get(n: string): string | null };
text(): Promise<string>;
json(): Promise<unknown>;
};
const stubResponse: MinimalResponse = {
ok: true,
status: 200,
statusText: "OK",
headers: { get: headersGet },
text: async () => "",
json: async () => ({ id: "1", name: "general", type: 0 }),
};
let mode: "json" | "text" | "rate" = "json";
const stub404: MinimalResponse = {
ok: false,
status: 404,
statusText: "Not Found",
headers: { get: headersGet },
text: async () => "missing",
json: async () => ({}),
};
const stub429: MinimalResponse = {
ok: false,
status: 429,
statusText: "Too Many",
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
text: async () => "",
json: async () => ({}),
};
globalThis.fetch = (async () => {
if (mode === "json") return stubResponse;
if (mode === "text") return stub404;
return stub429;
}) as unknown as typeof fetch;
try {
const c = new DiscordBotClient({ token: "abc" });
const ch = await c.getChannel("1");
expect(ch.name).toBe("general");
mode = "text";
await expect(c.getChannel("2")).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
});
mode = "rate";
await expect(c.getChannel("3")).rejects.toMatchObject({
code: DISCORD_CODES.RATE_LIMITED,
});
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,184 @@
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import {
type DiscordBotConfig,
DiscordBotConfigSchema,
type DiscordChannel,
type DiscordMessage,
DiscordMessageSchema,
type DiscordResponse,
type FetchLike,
} from "../../types/discord-client.types.js";
import { executeDiscordRequest } from "../../utils/http.js";
const DEFAULT_BASE_URL = "https://discord.com/api/v10";
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Client for the Discord Bot API. Sends, edits, deletes messages; fetches
* channels; and creates reactions using a bot token. Runtime-agnostic via
* injectable `fetch` and `clock`.
*/
export class DiscordBotClient {
protected readonly token: string;
protected readonly baseUrl: string;
protected readonly timeoutMs: number;
protected readonly fetchFn: FetchLike;
protected readonly clock: Clock;
protected readonly messages = new Map<string, DiscordResponse>();
public constructor(config: DiscordBotConfig) {
const parsed = DiscordBotConfigSchema.safeParse(config);
if (!parsed.success) {
// Zod guarantees `issues` is non-empty when `success` is false.
const first = parsed.error.issues[0] as {
path: PropertyKey[];
message: string;
};
const missingToken = first.path[0] === "token";
throw new DiscordClientError(
missingToken
? DISCORD_CODES.CONFIG_MISSING
: DISCORD_CODES.INVALID_CONFIG,
first.message
);
}
this.token = config.token;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchFn =
config.fetch ??
((url, init) =>
fetch(url, init as RequestInit).then((res) => ({
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: { get: (n) => res.headers.get(n) },
text: () => res.text(),
json: () => res.json(),
})));
this.clock = config.clock ?? Date.now;
}
/** Send a message to a Discord channel. */
public async sendMessage(
channelId: string,
message: DiscordMessage
): Promise<DiscordResponse> {
this.validateMessage(message);
const body = this.buildMessageBody(message);
const url = `${this.baseUrl}/channels/${channelId}/messages`;
const response = await this.request<DiscordResponse>("POST", url, body);
this.messages.set(response.id, response);
return response;
}
/** Edit an existing message. */
public async editMessage(
channelId: string,
messageId: string,
message: Partial<DiscordMessage>
): Promise<DiscordResponse> {
if (message.content !== undefined) {
this.validateMessage({ ...message, content: message.content });
}
const body = this.buildMessageBody(message);
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
const response = await this.request<DiscordResponse>(
"PATCH",
url,
body
);
this.messages.set(response.id, response);
return response;
}
/** Delete a message from a channel. */
public async deleteMessage(
channelId: string,
messageId: string
): Promise<void> {
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
await this.request<void>("DELETE", url);
this.messages.delete(messageId);
}
/** Get channel information. */
public async getChannel(channelId: string): Promise<DiscordChannel> {
const url = `${this.baseUrl}/channels/${channelId}`;
return this.request<DiscordChannel>("GET", url);
}
/** Get guild metadata (drives the GUILD_NOT_FOUND error mapping on 404). */
public async getGuild(
guildId: string
): Promise<{ id: string; name: string }> {
const url = `${this.baseUrl}/guilds/${guildId}`;
return this.request<{ id: string; name: string }>("GET", url);
}
/** Add a reaction to a message. */
public async createReaction(
channelId: string,
messageId: string,
emoji: string
): Promise<void> {
const encoded = encodeURIComponent(emoji);
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`;
await this.request<void>("PUT", url);
}
/** Get a previously sent message by ID from the in-memory cache. */
public getStoredMessage(messageId: string): DiscordResponse | undefined {
return this.messages.get(messageId);
}
protected validateMessage(message: DiscordMessage): void {
const result = DiscordMessageSchema.safeParse(message);
if (!result.success) {
const first = result.error.issues[0] as { message: string };
throw new DiscordClientError(
DISCORD_CODES.INVALID_MESSAGE,
first.message
);
}
}
protected buildMessageBody(
message: Partial<DiscordMessage>
): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (message.content !== undefined) body["content"] = message.content;
if (message.embeds) body["embeds"] = message.embeds;
if (message.username) body["username"] = message.username;
if (message.avatarUrl) body["avatar_url"] = message.avatarUrl;
if (message.tts !== undefined) body["tts"] = message.tts;
return body;
}
protected async request<T>(
method: string,
url: string,
body?: Record<string, unknown>
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bot ${this.token}`,
};
if (body) headers["Content-Type"] = "application/json";
return executeDiscordRequest<T>({
fetch: this.fetchFn,
clock: this.clock,
method,
url,
headers,
...(body ? { body } : {}),
timeoutMs: this.timeoutMs,
kind: "bot",
isGuildRequest: url.includes("/guilds/"),
});
}
}

View File

@@ -0,0 +1 @@
export { DiscordBotClient } from "./discord-bot-client.js";

View File

@@ -0,0 +1,2 @@
export { DiscordBotClient } from "./bot-client/discord-bot-client.js";
export { DiscordWebhook } from "./webhook/discord-webhook.js";

View File

@@ -0,0 +1,94 @@
# Discord Webhook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks
are URLs that anyone with the URL can POST to — no bot token required — and
they accept the same message shape as the bot API. This module wraps that
endpoint with Zod boundary validation, injectable fetch/clock, OpenTelemetry
instrumentation (inherited from the shared HTTP funnel), and a structured
`DiscordDeliveryReport` return value so callers get persistable delivery
telemetry rather than a raw Discord response.
## Features
- `send(message)`, `sendText(content)`, `sendEmbed(embed)` convenience methods.
- Defaults `username` and `avatarUrl` from the constructor when the message
payload omits them.
- Threaded webhook routing via `?thread_id=<id>` query string.
- Always sets `?wait=true` so Discord returns the persisted message ID,
required for the `DiscordDeliveryReport.messageId` field.
- Boundary validation:
- `DiscordWebhookConfigSchema` at construction; missing/non-string URL →
`DISCORD.CONFIG_MISSING`, other failures (bad URL shape, wrong host) →
`DISCORD.INVALID_CONFIG`.
- `DiscordMessageSchema` on every `send` call → `DISCORD.INVALID_MESSAGE`.
- Uses the shared `executeDiscordRequest` funnel so error mapping
(401/403/404/429/5xx) is identical to the bot client; on the webhook path,
generic non-ok responses raise `DISCORD.WEBHOOK_FAILED`.
## Usage: Basic
```ts
import { DiscordWebhook } from "@suaveplan/discord-client";
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("deploy succeeded");
```
## Usage: Advanced
```ts
import { DiscordWebhook, type FetchLike } from "@suaveplan/discord-client";
const stub: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "42", channel_id: "1" }),
});
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/1/tok",
username: "deploybot",
fetch: stub,
clock: () => 1_700_000_000_000,
});
const report = await hook.sendEmbed({
title: "Deploy",
description: "succeeded",
color: 0x00ff00,
});
expect(report.status).toBe("delivered");
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
```
## API Reference
- `new DiscordWebhook(config: DiscordWebhookConfig)` — Zod-validated;
missing or non-string `webhookUrl` raises `DISCORD.CONFIG_MISSING`; any
other validation failure (non-Discord host, bad URL) raises
`DISCORD.INVALID_CONFIG`.
- `send(message: DiscordMessage): Promise<DiscordDeliveryReport>`.
- `sendText(content: string): Promise<DiscordDeliveryReport>`.
- `sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport>`.
## Implementation Notes
`buildReport` derives the `timestamp` from the injected `Clock` rather than
calling `new Date()` so tests can pin the value deterministically. The
`errorCode` and `errorMessage` fields on the report are only populated when
the caller explicitly constructs a failure report — in the happy path the
report carries `status: "delivered"` and no error fields. The webhook URL is
appended with `?wait=true` unconditionally because Discord returns 204 (no
body) when `wait` is absent, which would leave the delivery report without an
ID.

View File

@@ -0,0 +1,269 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../../types/discord-client.types.js";
import { DiscordWebhook } from "./discord-webhook.js";
const VALID_URL = "https://discord.com/api/webhooks/123/abctoken";
function ok(): DiscordFetchResponse {
return {
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "msg", channel_id: "1" }),
};
}
function makeFetch(): {
fetch: FetchLike;
calls: { url: string; method: string; body?: string }[];
} {
const calls: { url: string; method: string; body?: string }[] = [];
const fetchFn: FetchLike = async (url, init) => {
calls.push({
url,
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
});
return ok();
};
return { fetch: fetchFn, calls };
}
describe("DiscordWebhook constructor", () => {
it("rejects missing webhookUrl with DISCORD.CONFIG_MISSING", {
timeout: 5000,
}, () => {
try {
new DiscordWebhook({ webhookUrl: undefined as unknown as string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(DiscordClientError);
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.CONFIG_MISSING
);
}
});
it("rejects non-Discord URL with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordWebhook({ webhookUrl: "https://example.com/hook" });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("accepts a valid Discord webhook URL", { timeout: 5000 }, () => {
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
expect(w).toBeInstanceOf(DiscordWebhook);
});
});
describe("DiscordWebhook.send", () => {
it("POSTs with ?wait=true and returns a delivered report", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
fetch,
clock: () => 1_700_000_000_000,
});
const report = await w.send({ content: "hi" });
expect(report.status).toBe("delivered");
expect(report.messageId).toBe("msg");
expect(report.channelId).toBe("1");
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true`);
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("hi");
});
it("appends thread_id when provided", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.send({ content: "hi", threadId: "999" });
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true&thread_id=999`);
});
it("uses constructor username/avatarUrl as defaults", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
username: "default-user",
avatarUrl: "https://example.com/default.png",
fetch,
});
await w.send({ content: "hi" });
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["username"]).toBe("default-user");
expect(body["avatar_url"]).toBe("https://example.com/default.png");
});
it("per-message overrides take precedence over constructor defaults", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
username: "default",
fetch,
});
await w.send({
content: "hi",
username: "override",
avatarUrl: "https://example.com/o.png",
tts: true,
embeds: [{ title: "t" }],
});
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["username"]).toBe("override");
expect(body["avatar_url"]).toBe("https://example.com/o.png");
expect(body["tts"]).toBe(true);
expect(body["embeds"]).toEqual([{ title: "t" }]);
});
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
fetch: async () => ok(),
});
await expect(
w.send({ content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
});
describe("DiscordWebhook.sendText / sendEmbed", () => {
it("sendText wraps content in DiscordMessage", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.sendText("hi");
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("hi");
});
it("sendEmbed sends an embed array with empty content", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.sendEmbed({ title: "t" });
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("");
expect(body["embeds"]).toEqual([{ title: "t" }]);
});
});
describe("DiscordWebhook upstream errors", () => {
it("maps 5xx to DISCORD.WEBHOOK_FAILED", { timeout: 30000 }, async () => {
const fetchFn: FetchLike = async () => ({
ok: false,
status: 502,
statusText: "Bad Gateway",
headers: { get: () => null },
text: async () => "oops",
json: async () => ({}),
});
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch: fetchFn });
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.WEBHOOK_FAILED,
});
});
});
describe("DiscordWebhook defaults branch", () => {
it("uses globalThis.fetch and Date.now when not injected", {
timeout: 30000,
}, async () => {
const originalFetch = globalThis.fetch;
type MinimalResponse = {
ok: boolean;
status: number;
statusText: string;
headers: { get(n: string): string | null };
text(): Promise<string>;
json(): Promise<unknown>;
};
const stubResponse: MinimalResponse = {
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "msg", channel_id: "1" }),
};
let mode: "json" | "err" | "rate" = "json";
const stubErr: MinimalResponse = {
ok: false,
status: 500,
statusText: "Server Error",
headers: { get: () => null },
text: async () => "boom",
json: async () => ({}),
};
const stub429: MinimalResponse = {
ok: false,
status: 429,
statusText: "Too Many",
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
text: async () => "",
json: async () => ({}),
};
globalThis.fetch = (async () => {
if (mode === "json") return stubResponse;
if (mode === "err") return stubErr;
return stub429;
}) as unknown as typeof fetch;
try {
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
const report = await w.sendText("hi");
expect(report.status).toBe("delivered");
expect(report.messageId).toBe("msg");
mode = "err";
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.WEBHOOK_FAILED,
});
mode = "rate";
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.RATE_LIMITED,
});
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,123 @@
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import {
type DiscordDeliveryReport,
type DiscordEmbed,
type DiscordMessage,
DiscordMessageSchema,
type DiscordWebhookConfig,
DiscordWebhookConfigSchema,
type FetchLike,
} from "../../types/discord-client.types.js";
import { executeDiscordRequest } from "../../utils/http.js";
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Sends messages to Discord channels via webhook URLs. Supports plain text,
* embeds, and full message payloads. Returns a structured
* `DiscordDeliveryReport` so callers can persist delivery telemetry.
*/
export class DiscordWebhook {
protected readonly webhookUrl: string;
protected readonly username?: string;
protected readonly avatarUrl?: string;
protected readonly timeoutMs: number;
protected readonly fetchFn: FetchLike;
protected readonly clock: Clock;
public constructor(config: DiscordWebhookConfig) {
const parsed = DiscordWebhookConfigSchema.safeParse(config);
if (!parsed.success) {
const first = parsed.error.issues[0] as {
path: PropertyKey[];
code: string;
message: string;
};
const isMissingUrl =
first.path[0] === "webhookUrl" && first.code === "invalid_type";
throw new DiscordClientError(
isMissingUrl
? DISCORD_CODES.CONFIG_MISSING
: DISCORD_CODES.INVALID_CONFIG,
first.message
);
}
this.webhookUrl = config.webhookUrl;
if (config.username !== undefined) this.username = config.username;
if (config.avatarUrl !== undefined) this.avatarUrl = config.avatarUrl;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchFn =
config.fetch ??
((url, init) =>
fetch(url, init as RequestInit).then((res) => ({
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: { get: (n) => res.headers.get(n) },
text: () => res.text(),
json: () => res.json(),
})));
this.clock = config.clock ?? Date.now;
}
/** Send a full message payload via the webhook. */
public async send(message: DiscordMessage): Promise<DiscordDeliveryReport> {
const result = DiscordMessageSchema.safeParse(message);
if (!result.success) {
const first = result.error.issues[0] as { message: string };
throw new DiscordClientError(
DISCORD_CODES.INVALID_MESSAGE,
first.message
);
}
const body: Record<string, unknown> = {
content: message.content,
};
if (message.embeds) body["embeds"] = message.embeds;
if (message.tts !== undefined) body["tts"] = message.tts;
const username = message.username ?? this.username;
if (username !== undefined) body["username"] = username;
const avatarUrl = message.avatarUrl ?? this.avatarUrl;
if (avatarUrl !== undefined) body["avatar_url"] = avatarUrl;
const url = message.threadId
? `${this.webhookUrl}?wait=true&thread_id=${message.threadId}`
: `${this.webhookUrl}?wait=true`;
const data = await executeDiscordRequest<{
id: string;
channel_id: string;
}>({
fetch: this.fetchFn,
clock: this.clock,
method: "POST",
url,
headers: { "Content-Type": "application/json" },
body,
timeoutMs: this.timeoutMs,
kind: "webhook",
});
return {
messageId: data.id,
channelId: data.channel_id,
status: "delivered",
timestamp: new Date(this.clock()),
};
}
/** Send a plain-text message via the webhook. */
public sendText(content: string): Promise<DiscordDeliveryReport> {
return this.send({ content });
}
/** Send a single embed via the webhook. */
public sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport> {
return this.send({ content: "", embeds: [embed] });
}
}

View File

@@ -0,0 +1 @@
export { DiscordWebhook } from "./discord-webhook.js";

View File

@@ -0,0 +1,84 @@
# Discord Client Error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the namespaced, frozen error-code registry and the typed
`DiscordClientError` class used by every runtime failure path in
`@suaveplan/discord-client`. Co-locating the codes with the error class
guarantees that adding a new code is a single-file change and that the
TypeScript union stays in sync with the runtime registry the conformance
audit greps for.
## Features
- `DISCORD_CODES` — a `defineCodes("DISCORD", [...])` frozen record. Each entry
has the form `DISCORD.<REASON>` so it is globally unique across the
shared code space.
- `DiscordClientCode` — type alias for the union of every value in
`DISCORD_CODES`.
- `DiscordClientError` — extends `SuaveplanError<DiscordClientCode>` so
callers can do `instanceof DiscordClientError` to narrow on the code union
AND/OR pattern-match on `.code` for stable programmatic handling.
## Usage: Basic
```ts
import {
DISCORD_CODES,
DiscordClientError,
} from "@suaveplan/discord-client/errors";
throw new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"discord.errors.authError"
);
```
## Usage: Advanced
```ts
import { DiscordClientError, DISCORD_CODES } from "@suaveplan/discord-client";
try {
await client.sendMessage("1", { content: "hi" });
} catch (err) {
if (err instanceof DiscordClientError) {
switch (err.code) {
case DISCORD_CODES.RATE_LIMITED:
// back off using `err.context?.retryAfterMs`
break;
case DISCORD_CODES.FORBIDDEN:
case DISCORD_CODES.AUTH_ERROR:
// surface to ops, do not retry
break;
default:
// generic retry / report
break;
}
}
}
```
## API Reference
- `DISCORD_CODES`: frozen record. Keys: `API_ERROR`, `AUTH_ERROR`,
`RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`,
`INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`,
`FORBIDDEN`. Values are the `DISCORD.<REASON>` strings.
- `DiscordClientCode`: `typeof DISCORD_CODES[keyof typeof DISCORD_CODES]`.
- `DiscordClientError(code, messageKey, options?)`: typed error subclass.
`options` is the `SuaveplanErrorOptions` bag — pass `cause`, `context`,
`traceId`, or `clock` through.
## Implementation Notes
Every code in this registry is thrown by at least one production code path
inside `src/utils/http.ts`, `src/core/bot-client/discord-bot-client.ts`, or
`src/core/webhook/discord-webhook.ts`. The conformance audit
(`bun scripts/conformance.ts @suaveplan/discord-client`) greps for
exactly that mapping; do not add a code here unless you wire a real throw
site for it in the same change.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./discord-client-error.js";
describe("DISCORD_CODES registry", () => {
it("is frozen and namespaces every code with DISCORD.", {
timeout: 5000,
}, () => {
expect(Object.isFrozen(DISCORD_CODES)).toBe(true);
for (const value of Object.values(DISCORD_CODES)) {
expect(value.startsWith("DISCORD.")).toBe(true);
}
});
it("exposes all 11 documented codes", { timeout: 5000 }, () => {
expect(Object.keys(DISCORD_CODES).sort()).toEqual(
[
"API_ERROR",
"AUTH_ERROR",
"RATE_LIMITED",
"CHANNEL_NOT_FOUND",
"GUILD_NOT_FOUND",
"CONFIG_MISSING",
"INVALID_CONFIG",
"INVALID_MESSAGE",
"WEBHOOK_FAILED",
"TIMEOUT",
"FORBIDDEN",
].sort()
);
});
});
describe("DiscordClientError", () => {
it("extends SuaveplanError and carries the code", { timeout: 5000 }, () => {
const err = new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"auth failed"
);
expect(err).toBeInstanceOf(DiscordClientError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(DISCORD_CODES.AUTH_ERROR);
expect(err.message).toBe("auth failed");
expect(err.name).toBe("DiscordClientError");
});
it("forwards options to SuaveplanError", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new DiscordClientError(
DISCORD_CODES.TIMEOUT,
"Discord timed out",
{ cause, context: { url: "https://x" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ url: "https://x" });
expect(err.timestamp).toBeGreaterThan(0);
});
it("narrows on the code literal union", { timeout: 5000 }, () => {
const code: DiscordClientCode = DISCORD_CODES.FORBIDDEN;
expect(code).toBe("DISCORD.FORBIDDEN");
});
});

View File

@@ -0,0 +1,41 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, frozen error-code registry for `@suaveplan/discord-client`. Every
* runtime failure thrown by this package carries one of these codes.
*/
export const DISCORD_CODES = defineCodes("DISCORD", [
"API_ERROR",
"AUTH_ERROR",
"RATE_LIMITED",
"CHANNEL_NOT_FOUND",
"GUILD_NOT_FOUND",
"CONFIG_MISSING",
"INVALID_CONFIG",
"INVALID_MESSAGE",
"WEBHOOK_FAILED",
"TIMEOUT",
"FORBIDDEN",
] as const);
export type DiscordClientCode =
(typeof DISCORD_CODES)[keyof typeof DISCORD_CODES];
/**
* Typed error class for every `@suaveplan/discord-client` failure.
*
* Extends `SuaveplanError<DiscordClientCode>` so callers can either
* `instanceof DiscordClientError` to narrow on the code union OR pattern-match
* on the stable `.code` string.
*/
export class DiscordClientError extends SuaveplanError<DiscordClientCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
public constructor(
code: DiscordClientCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,5 @@
export {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./discord-client-error.js";

View File

@@ -0,0 +1,38 @@
// Core clients
export { DiscordBotClient } from "./core/bot-client/discord-bot-client.js";
export { DiscordWebhook } from "./core/webhook/discord-webhook.js";
// Errors
export {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./errors/discord-client-error.js";
export type {
DiscordBotConfig,
DiscordChannel,
DiscordClientCommonInjectables,
DiscordDeliveryReport,
DiscordDeliveryStatus,
DiscordEmbed,
DiscordEmbedField,
DiscordFetchResponse,
DiscordMessage,
DiscordResponse,
DiscordWebhookConfig,
FetchLike,
} from "./types/discord-client.types.js";
// Types
export {
DiscordBotConfigSchema,
DiscordMessageSchema,
DiscordWebhookConfigSchema,
} from "./types/discord-client.types.js";
// Utils (advanced — exposed for custom routes)
export {
type DiscordRequestKind,
type DiscordRequestOptions,
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "./utils/http.js";
export { parseRetryAfterMs } from "./utils/rate-limit.js";

View File

@@ -0,0 +1,83 @@
# Discord Client Types
## Purpose
Authoritative public type surface for `@suaveplan/discord-client`. Defines every
data shape the bot client and webhook client emit or accept, plus the runtime
Zod schemas used to validate configuration and message payloads at the public
boundary. Centralising the types here guarantees consumers can import them
without pulling in any runtime code, and ensures Zod schemas stay in lockstep
with the TypeScript surface they validate.
## Features
- `DiscordBotConfig` / `DiscordWebhookConfig` with injectable `clock` and
`fetch` for deterministic testing.
- `DiscordMessage` / `DiscordEmbed` / `DiscordEmbedField` mirroring the Discord
v10 API payload contracts (with field-length caps enforced by Zod).
- `DiscordResponse`, `DiscordChannel`, `DiscordDeliveryReport`,
`DiscordDeliveryStatus` covering every value returned to callers.
- `FetchLike` and `DiscordFetchResponse` describe the minimal subset of the
fetch contract the client depends on — tests inject a deterministic stub.
- Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`,
`DiscordMessageSchema`) validate config and message payloads.
## Usage: Basic
```ts
import { DiscordMessageSchema, type DiscordMessage } from "@suaveplan/discord-client/types";
const msg: DiscordMessage = { content: "hello" };
const parsed = DiscordMessageSchema.parse(msg);
```
## Usage: Advanced
```ts
import {
DiscordBotConfigSchema,
type DiscordBotConfig,
type FetchLike,
} from "@suaveplan/discord-client/types";
const stubFetch: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({}),
});
const config: DiscordBotConfig = {
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
};
DiscordBotConfigSchema.parse(config);
```
## API Reference
- `DiscordBotConfig`: token, baseUrl, timeoutMs, plus injectable `clock`/`fetch`.
- `DiscordWebhookConfig`: webhookUrl (must contain `discord.com/api/webhooks/`),
username, avatarUrl, timeoutMs, plus injectables.
- `DiscordMessage`: content (≤2000), embeds (≤10), username, avatarUrl, tts,
threadId.
- `DiscordEmbed`: full Discord embed object with caps on each field.
- `DiscordResponse`: id, channelId, content, timestamp, embeds.
- `DiscordChannel`: id, name, type, guildId, topic.
- `DiscordDeliveryReport`: messageId, channelId, status, timestamp, errorCode,
errorMessage.
- `DiscordMessageSchema` / `DiscordBotConfigSchema` /
`DiscordWebhookConfigSchema`: Zod parsers used at every public entrypoint.
## Implementation Notes
`FetchLike` purposefully omits headers initialisation, body streaming, and
other parts of the WHATWG Response API the client never reads. This keeps the
test stub small and avoids tying the package to a specific runtime's fetch
quirks. `DiscordBotConfigSchema` accepts the injectables as `z.unknown()` —
their structural shape is enforced by the static `FetchLike` / `Clock` types,
not at runtime, because schemas cannot reliably introspect function signatures
across Bun and Node without false negatives.

View File

@@ -0,0 +1,196 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
/**
* Minimal injectable fetch contract. Matches the relevant subset of the
* global `fetch` shape so callers can pass `globalThis.fetch` directly OR
* a deterministic stub in tests. We avoid `typeof fetch` to keep the
* contract tight and runtime-agnostic.
*/
export type FetchLike = (
url: string,
init: {
method: string;
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
}
) => Promise<DiscordFetchResponse>;
/** Subset of the `Response` shape consumed by the Discord client. */
export interface DiscordFetchResponse {
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly headers: {
get(name: string): string | null;
};
text(): Promise<string>;
json(): Promise<unknown>;
}
/** Supported HMAC-of-time-source for any timestamp the client emits. */
export interface DiscordClientCommonInjectables {
/** Wall-clock used for delivery report `timestamp`s. */
clock?: Clock;
/** Injectable fetch implementation. Defaults to `globalThis.fetch`. */
fetch?: FetchLike;
}
/** Configuration for the Discord bot client. */
export interface DiscordBotConfig extends DiscordClientCommonInjectables {
/** Bot authentication token (without the `Bot ` prefix). */
token: string;
/** Base URL for the Discord API. Defaults to `https://discord.com/api/v10`. */
baseUrl?: string;
/** Request timeout in milliseconds. Defaults to `10000`. */
timeoutMs?: number;
}
/** Configuration for a Discord webhook. */
export interface DiscordWebhookConfig extends DiscordClientCommonInjectables {
/** Full webhook URL including token (must include `discord.com/api/webhooks/`). */
webhookUrl: string;
/** Default username override for messages. */
username?: string;
/** Default avatar URL override for messages. */
avatarUrl?: string;
/** Request timeout in milliseconds. Defaults to `10000`. */
timeoutMs?: number;
}
/** A Discord embed field. */
export interface DiscordEmbedField {
name: string;
value: string;
inline?: boolean;
}
/** A Discord embed object. */
export interface DiscordEmbed {
title?: string;
description?: string;
url?: string;
/** Decimal color value. */
color?: number;
/** ISO 8601 timestamp. */
timestamp?: string;
footer?: { text: string; iconUrl?: string };
thumbnail?: { url: string };
image?: { url: string };
author?: { name: string; url?: string; iconUrl?: string };
fields?: DiscordEmbedField[];
}
/** A Discord message payload. */
export interface DiscordMessage {
/** Text content of the message. */
content: string;
/** Rich embed objects to include. */
embeds?: DiscordEmbed[];
/** Username override for this message. */
username?: string;
/** Avatar URL override for this message. */
avatarUrl?: string;
/** Whether to send as text-to-speech. */
tts?: boolean;
/** Thread ID to send the message to. */
threadId?: string;
}
/** Response from the Discord API after sending a message. */
export interface DiscordResponse {
id: string;
channelId: string;
content: string;
timestamp: string;
embeds?: DiscordEmbed[];
}
/** A Discord channel object. */
export interface DiscordChannel {
id: string;
name: string;
type: number;
guildId?: string;
topic?: string;
}
/** Status of a Discord message delivery. */
export type DiscordDeliveryStatus = "queued" | "sent" | "delivered" | "failed";
/** Report on the delivery status of a Discord message. */
export interface DiscordDeliveryReport {
messageId: string;
channelId: string;
status: DiscordDeliveryStatus;
timestamp: Date;
errorCode?: string;
errorMessage?: string;
}
// ── Zod boundary schemas ─────────────────────────────────────────────────────
const embedFieldSchema = z.object({
name: z.string().min(1).max(256),
value: z.string().min(1).max(1024),
inline: z.boolean().optional(),
});
const embedSchema = z.object({
title: z.string().max(256).optional(),
description: z.string().max(4096).optional(),
url: z.string().url().optional(),
color: z.number().int().min(0).max(0xffffff).optional(),
timestamp: z.string().optional(),
footer: z
.object({
text: z.string().max(2048),
iconUrl: z.string().url().optional(),
})
.optional(),
thumbnail: z.object({ url: z.string().url() }).optional(),
image: z.object({ url: z.string().url() }).optional(),
author: z
.object({
name: z.string().min(1).max(256),
url: z.string().url().optional(),
iconUrl: z.string().url().optional(),
})
.optional(),
fields: z.array(embedFieldSchema).max(25).optional(),
});
/** Zod schema validating a `DiscordMessage` payload at the public boundary. */
export const DiscordMessageSchema = z.object({
content: z.string().max(2000),
embeds: z.array(embedSchema).max(10).optional(),
username: z.string().min(1).max(80).optional(),
avatarUrl: z.string().url().optional(),
tts: z.boolean().optional(),
threadId: z.string().regex(/^\d+$/).optional(),
});
/** Zod schema validating a `DiscordBotConfig` at construction time. */
export const DiscordBotConfigSchema = z.object({
token: z.string().min(1),
baseUrl: z.string().url().optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z.unknown().optional(),
fetch: z.unknown().optional(),
});
/** Zod schema validating a `DiscordWebhookConfig` at construction time. */
export const DiscordWebhookConfigSchema = z.object({
webhookUrl: z
.string()
.url()
.refine((u) => u.includes("discord.com/api/webhooks/"), {
message: "webhookUrl must be a discord.com/api/webhooks/ URL",
}),
username: z.string().min(1).max(80).optional(),
avatarUrl: z.string().url().optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z.unknown().optional(),
fetch: z.unknown().optional(),
});

View File

@@ -0,0 +1,19 @@
export type {
DiscordBotConfig,
DiscordChannel,
DiscordClientCommonInjectables,
DiscordDeliveryReport,
DiscordDeliveryStatus,
DiscordEmbed,
DiscordEmbedField,
DiscordFetchResponse,
DiscordMessage,
DiscordResponse,
DiscordWebhookConfig,
FetchLike,
} from "./discord-client.types.js";
export {
DiscordBotConfigSchema,
DiscordMessageSchema,
DiscordWebhookConfigSchema,
} from "./discord-client.types.js";

View File

@@ -0,0 +1,90 @@
# Discord HTTP Helper
## Purpose
Single funnel through which every Discord API and webhook request flows. It
maps Discord response codes onto the `DiscordClientError` taxonomy, attaches
OpenTelemetry counters and histograms, enforces an `AbortController`-driven
timeout, and treats abort errors as `DISCORD.TIMEOUT`. Centralising this logic
guarantees identical retry hints, error codes, and observability across the
bot client and webhook client — the two entrypoints differ only in their URL
construction and choice of `kind`.
## Features
- Maps `401 / 403 / 404 / 429 / 5xx / other-non-ok` Discord responses onto
stable `DISCORD.*` codes, with per-kind dispatch for the webhook path.
- Differentiates guild vs channel 404s via `isGuildRequest`.
- Pulls `Retry-After` data via `parseRetryAfterMs` and surfaces it on the
thrown error's `context.retryAfterMs` so callers can honour Discord's
back-off recommendation without re-parsing headers.
- Lazily creates `discord_client.request.total` counter and
`discord_client.request.duration_ms` histogram against the global OTel
meter provider. `resetDiscordHttpMetrics()` invalidates the cache so tests
can swap in an in-memory `MeterProvider`.
- Treats `AbortError` (raised by the timeout) as `DISCORD.TIMEOUT`; every
other fetch rejection becomes `DISCORD.API_ERROR` or `DISCORD.WEBHOOK_FAILED`
per the request kind.
- Returns `undefined` for HTTP 204 (Discord's success-with-no-body status,
used by `DELETE` and `PUT` reactions).
## Usage: Basic
```ts
import { executeDiscordRequest } from "@suaveplan/discord-client";
const channel = await executeDiscordRequest<DiscordChannel>({
fetch,
clock: Date.now,
method: "GET",
url: "https://discord.com/api/v10/channels/1",
headers: { Authorization: "Bot abc" },
timeoutMs: 10_000,
kind: "bot",
});
```
## Usage: Advanced
```ts
import {
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "@suaveplan/discord-client";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const mp = new MeterProvider();
metrics.setGlobalMeterProvider(mp);
resetDiscordHttpMetrics();
await executeDiscordRequest({
fetch: stub,
clock: () => 1700000000000,
method: "POST",
url: "https://discord.com/api/v10/channels/1/messages",
headers: { Authorization: "Bot abc", "Content-Type": "application/json" },
body: { content: "hi" },
timeoutMs: 10_000,
kind: "bot",
});
```
## API Reference
- `executeDiscordRequest<T>(opts: DiscordRequestOptions): Promise<T>` — main
entrypoint. Throws `DiscordClientError`.
- `resetDiscordHttpMetrics()` — clears the cached meter / counter / histogram
so the next call rebinds to the current global meter provider. Used by tests.
- `DiscordRequestKind = "bot" | "webhook"` — drives the error code on
`WEBHOOK_FAILED` vs `API_ERROR`.
## Implementation Notes
The function intentionally records a metric on every outcome (`ok`,
`auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`,
`network_error`, `api_error`). Histograms are emitted with the same label
set as the counter so dashboards can divide one by the other directly.
The 401/403/429/404/non-ok ordering matches Discord's documented
precedence; non-ok responses fall through to the final clause, which
distinguishes `DISCORD.API_ERROR` (bot) from `DISCORD.WEBHOOK_FAILED`
(webhook) so the error registry's retry/severity hints stay accurate.

View File

@@ -0,0 +1,350 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
MetricOptions,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../types/discord-client.types.js";
import { executeDiscordRequest, resetDiscordHttpMetrics } from "./http.js";
// ── In-memory MeterProvider recording every counter add / histogram record ──
interface CounterRecord {
name: string;
value: number;
attrs?: Record<string, string>;
}
interface HistogramRecord {
name: string;
value: number;
attrs?: Record<string, string>;
}
function makeRecordingProvider(): {
provider: MeterProvider;
counters: CounterRecord[];
histograms: HistogramRecord[];
counterNames: string[];
histogramNames: string[];
} {
const counters: CounterRecord[] = [];
const histograms: HistogramRecord[] = [];
const counterNames: string[] = [];
const histogramNames: string[] = [];
const meter: Partial<Meter> = {
createCounter(name: string, _opts?: MetricOptions): Counter {
counterNames.push(name);
return {
add: (value: number, attrs?: Record<string, string>) => {
counters.push({ name, value, attrs });
},
} as unknown as Counter;
},
createHistogram(name: string, _opts?: MetricOptions): Histogram {
histogramNames.push(name);
return {
record: (value: number, attrs?: Record<string, string>) => {
histograms.push({ name, value, attrs });
},
} as unknown as Histogram;
},
};
const provider: Partial<MeterProvider> = {
getMeter: (() => meter as Meter) as MeterProvider["getMeter"],
};
return {
provider: provider as MeterProvider,
counters,
histograms,
counterNames,
histogramNames,
};
}
function makeResponse(init: {
ok?: boolean;
status: number;
statusText?: string;
headers?: Record<string, string>;
body?: unknown;
text?: string;
}): DiscordFetchResponse {
const headers = init.headers ?? {};
return {
ok: init.ok ?? (init.status >= 200 && init.status < 300),
status: init.status,
statusText: init.statusText ?? "OK",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => init.text ?? "",
json: async () => init.body ?? {},
};
}
const baseOpts = (
fetchFn: FetchLike,
overrides: Partial<Parameters<typeof executeDiscordRequest>[0]> = {}
): Parameters<typeof executeDiscordRequest>[0] => ({
fetch: fetchFn,
clock: () => 1_700_000_000_000,
method: "GET",
url: "https://discord.com/api/v10/channels/1",
headers: { Authorization: "Bot abc" },
timeoutMs: 10_000,
kind: "bot",
...overrides,
});
describe("executeDiscordRequest", () => {
beforeEach(() => {
resetDiscordHttpMetrics();
});
it("returns parsed JSON for 2xx responses", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 200, body: { id: "1" } });
const result = await executeDiscordRequest<{ id: string }>(
baseOpts(stub)
);
expect(result).toEqual({ id: "1" });
});
it("returns undefined for 204 responses", { timeout: 30000 }, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 204, ok: true });
const result = await executeDiscordRequest<unknown>(baseOpts(stub));
expect(result).toBeUndefined();
});
it("maps 401 to DISCORD.AUTH_ERROR", { timeout: 30000 }, async () => {
const stub: FetchLike = async () => makeResponse({ status: 401 });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.AUTH_ERROR });
});
it("maps 403 to DISCORD.FORBIDDEN", { timeout: 30000 }, async () => {
const stub: FetchLike = async () => makeResponse({ status: 403 });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.FORBIDDEN });
});
it("maps 404 channel responses to DISCORD.CHANNEL_NOT_FOUND", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 404, text: "channel" });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
message: "channel",
});
});
it("maps 404 guild responses to DISCORD.GUILD_NOT_FOUND when isGuildRequest", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 404, text: "" });
await expect(
executeDiscordRequest(
baseOpts(stub, {
url: "https://discord.com/api/v10/guilds/1",
isGuildRequest: true,
})
)
).rejects.toMatchObject({
code: DISCORD_CODES.GUILD_NOT_FOUND,
});
});
it("maps 429 to DISCORD.RATE_LIMITED with retryAfterMs context", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({
status: 429,
headers: { "retry-after": "2" },
});
const err = await executeDiscordRequest(baseOpts(stub)).catch(
(e) => e as DiscordClientError
);
expect(err).toBeInstanceOf(DiscordClientError);
expect((err as DiscordClientError).code).toBe(
DISCORD_CODES.RATE_LIMITED
);
expect((err as DiscordClientError).context?.["retryAfterMs"]).toBe(
2000
);
});
it("maps generic 5xx on bot path to DISCORD.API_ERROR", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 502, text: "bad gateway" });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("maps generic 5xx on webhook path to DISCORD.WEBHOOK_FAILED", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 500, text: "server error" });
await expect(
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
});
it("maps AbortError to DISCORD.TIMEOUT", { timeout: 30000 }, async () => {
const stub: FetchLike = async (_url, init) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
const abortErr = new Error("aborted");
abortErr.name = "AbortError";
reject(abortErr);
});
});
await expect(
executeDiscordRequest(baseOpts(stub, { timeoutMs: 10 }))
).rejects.toMatchObject({ code: DISCORD_CODES.TIMEOUT });
});
it("maps generic fetch rejections on bot path to DISCORD.API_ERROR", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw new Error("ECONNREFUSED");
};
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("maps generic fetch rejections on webhook path to DISCORD.WEBHOOK_FAILED", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw new Error("connection reset");
};
await expect(
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
});
it("wraps non-Error thrown values via String coercion", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw "string failure";
};
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("tolerates response.text() throwing on 404 path", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => ({
...makeResponse({ status: 404 }),
text: async () => {
throw new Error("body read failed");
},
});
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
});
});
it("tolerates response.text() throwing on generic error path", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => ({
...makeResponse({ status: 500 }),
text: async () => {
throw new Error("body read failed");
},
});
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.API_ERROR,
});
});
it("serialises body and passes signal", { timeout: 30000 }, async () => {
let captured:
| { method: string; body?: string; hasSignal: boolean }
| undefined;
const stub: FetchLike = async (_url, init) => {
captured = {
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
hasSignal: init.signal !== undefined,
};
return makeResponse({ status: 200, body: { ok: true } });
};
await executeDiscordRequest(
baseOpts(stub, {
method: "POST",
body: { content: "hi" },
})
);
expect(captured?.method).toBe("POST");
expect(captured?.body).toBe('{"content":"hi"}');
expect(captured?.hasSignal).toBe(true);
});
it("emits OTel counters and histograms for ok and failure outcomes [spec-conformance]", {
timeout: 30000,
}, async () => {
const rec = makeRecordingProvider();
metrics.setGlobalMeterProvider(rec.provider);
resetDiscordHttpMetrics();
const okStub: FetchLike = async () =>
makeResponse({ status: 200, body: { id: "1" } });
await executeDiscordRequest(baseOpts(okStub));
const errStub: FetchLike = async () => makeResponse({ status: 401 });
await executeDiscordRequest(baseOpts(errStub)).catch(() => {});
expect(rec.counterNames).toContain("discord_client.request.total");
expect(rec.histogramNames).toContain(
"discord_client.request.duration_ms"
);
const outcomes = rec.counters.map((c) => c.attrs?.["outcome"]);
expect(outcomes).toContain("ok");
expect(outcomes).toContain("auth_error");
// every counter add carries a matching histogram record
expect(rec.histograms.length).toBe(rec.counters.length);
metrics.disable();
resetDiscordHttpMetrics();
});
});

View File

@@ -0,0 +1,176 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../types/discord-client.types.js";
import { parseRetryAfterMs } from "./rate-limit.js";
/** OTel metric handles, lazily created on first request. */
let requestCounter: Counter | undefined;
let requestDuration: Histogram | undefined;
let cachedMeter: Meter | undefined;
function getMetrics(): { counter: Counter; histogram: Histogram } {
if (!cachedMeter) {
cachedMeter = metrics.getMeter("@suaveplan/discord-client");
}
if (!requestCounter) {
requestCounter = cachedMeter.createCounter(
"discord_client.request.total",
{ description: "Total Discord API/webhook requests by outcome" }
);
}
if (!requestDuration) {
requestDuration = cachedMeter.createHistogram(
"discord_client.request.duration_ms",
{
description:
"Round-trip duration of every Discord request in ms",
unit: "ms",
}
);
}
return { counter: requestCounter, histogram: requestDuration };
}
/** Reset the cached OTel meter — tests use this to point at an in-memory MP. */
export function resetDiscordHttpMetrics(): void {
requestCounter = undefined;
requestDuration = undefined;
cachedMeter = undefined;
}
/** Kind of Discord request — drives the error-code mapping on 4xx. */
export type DiscordRequestKind = "bot" | "webhook";
export interface DiscordRequestOptions {
fetch: FetchLike;
clock: Clock;
method: string;
url: string;
headers: Record<string, string>;
body?: Record<string, unknown>;
timeoutMs: number;
kind: DiscordRequestKind;
/** Set when the request URL targets a guild (drives 404 → GUILD_NOT_FOUND). */
isGuildRequest?: boolean;
}
/**
* Executes a single Discord HTTP request, mapping the response onto the
* `DiscordClientError` taxonomy and recording OTel counters/histograms.
*
* Returns the parsed JSON body on success (or `undefined` on 204). Throws
* `DiscordClientError` for every documented Discord failure mode.
*/
export async function executeDiscordRequest<T>(
opts: DiscordRequestOptions
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
const started = opts.clock();
const { counter, histogram } = getMetrics();
const recordOutcome = (outcome: string, status: number | "none"): void => {
const duration = opts.clock() - started;
const labels = { kind: opts.kind, method: opts.method, outcome };
counter.add(1, { ...labels, status: String(status) });
histogram.record(duration, labels);
};
let response: DiscordFetchResponse;
try {
const init: {
method: string;
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
} = {
method: opts.method,
headers: opts.headers,
signal: controller.signal,
};
if (opts.body !== undefined) init.body = JSON.stringify(opts.body);
response = await opts.fetch(opts.url, init);
} catch (err) {
clearTimeout(timer);
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
recordOutcome("timeout", "none");
throw new DiscordClientError(
DISCORD_CODES.TIMEOUT,
`Discord request timed out after ${opts.timeoutMs}ms`,
{ context: { timeoutMs: opts.timeoutMs, url: opts.url } }
);
}
recordOutcome("network_error", "none");
const cause = err instanceof Error ? err : new Error(String(err));
throw new DiscordClientError(
opts.kind === "webhook"
? DISCORD_CODES.WEBHOOK_FAILED
: DISCORD_CODES.API_ERROR,
cause.message,
{ cause, context: { url: opts.url } }
);
}
clearTimeout(timer);
if (response.status === 401) {
recordOutcome("auth_error", 401);
throw new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"Discord credentials are invalid or expired"
);
}
if (response.status === 403) {
recordOutcome("forbidden", 403);
throw new DiscordClientError(
DISCORD_CODES.FORBIDDEN,
"Discord rejected the request: caller lacks required permissions"
);
}
if (response.status === 404) {
recordOutcome("not_found", 404);
const code =
opts.kind === "bot" && opts.isGuildRequest
? DISCORD_CODES.GUILD_NOT_FOUND
: DISCORD_CODES.CHANNEL_NOT_FOUND;
const text = await response.text().catch(() => "");
throw new DiscordClientError(
code,
text.length > 0 ? text : `Discord resource not found: ${opts.url}`
);
}
if (response.status === 429) {
recordOutcome("rate_limited", 429);
const retryAfterMs = parseRetryAfterMs(response, opts.clock);
throw new DiscordClientError(
DISCORD_CODES.RATE_LIMITED,
"Discord rate limit exceeded",
{ context: { retryAfterMs, url: opts.url } }
);
}
if (!response.ok) {
recordOutcome("api_error", response.status);
const text = await response.text().catch(() => "Unknown error");
const code =
opts.kind === "webhook"
? DISCORD_CODES.WEBHOOK_FAILED
: DISCORD_CODES.API_ERROR;
throw new DiscordClientError(
code,
`Discord ${opts.kind} error ${response.status}: ${text}`,
{ context: { status: response.status, url: opts.url } }
);
}
recordOutcome("ok", response.status);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}

View File

@@ -0,0 +1,7 @@
export {
type DiscordRequestKind,
type DiscordRequestOptions,
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "./http.js";
export { parseRetryAfterMs } from "./rate-limit.js";

View File

@@ -0,0 +1,67 @@
# Rate-Limit Header Parser
## Purpose
Parses Discord's three rate-limit response headers into a single millisecond
back-off duration that callers can use to schedule a retry. Discord is
inconsistent about which header it emits in which response (gateway-level
429s use `Retry-After`, bucket-level 429s use `X-RateLimit-Reset-After`, and
shard-level overflows can fall back to `X-RateLimit-Reset` as a wall-clock
target), so a single canonical parser ensures every code path reacts the same
way and that tests can pin every flavour with deterministic fixtures.
## Features
- Reads `Retry-After`, `X-RateLimit-Reset-After`, and `X-RateLimit-Reset`.
- Honours both seconds-as-float and HTTP-date encodings of `Retry-After`.
- Treats `X-RateLimit-Reset` values < 10^12 as Unix epoch seconds and ≥ 10^12
as Unix epoch milliseconds (Discord switched encodings mid-2023).
- Accepts an injectable `Clock` so tests stay deterministic.
- Returns `undefined` when no header is present or every header is unparsable.
## Usage: Basic
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const retryAfterMs = parseRetryAfterMs(response);
```
## Usage: Advanced
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const fixedClock = () => 1_700_000_000_000;
const fakeResponse = {
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: { get: (name: string) => (name === "x-ratelimit-reset" ? "1700000060" : null) },
text: async () => "",
json: async () => ({}),
};
// Returns 60_000 — the difference between epoch-second 1700000060 and the
// injected clock at 1_700_000_000_000 ms.
parseRetryAfterMs(fakeResponse, fixedClock);
```
## API Reference
`parseRetryAfterMs(response: DiscordFetchResponse, clock?: Clock): number | undefined`
- `response`: any object exposing `.headers.get(name)`.
- `clock`: optional `Clock` injectable; defaults to `Date.now`.
- Returns the parsed millisecond back-off, or `undefined` when no header was
parsable.
## Implementation Notes
`Math.ceil` is applied to the floating-point seconds to avoid under-waiting
when Discord emits fractional values like `0.250`. Negative deltas from
`X-RateLimit-Reset` clamp to zero rather than returning a stale duration —
the caller should retry immediately rather than reading a negative value. The
parser does NOT throw on malformed header values; it returns `undefined`,
which is the canonical "no advice from Discord, fall back to your local
retry policy" signal.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
import { parseRetryAfterMs } from "./rate-limit.js";
function makeResponse(
headers: Record<string, string | undefined>
): DiscordFetchResponse {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => "",
json: async () => ({}),
};
}
describe("parseRetryAfterMs", () => {
it("parses retry-after seconds as ceil-rounded ms", {
timeout: 5000,
}, () => {
const r = makeResponse({ "retry-after": "0.25" });
expect(parseRetryAfterMs(r, () => 0)).toBe(250);
});
it("parses x-ratelimit-reset-after seconds when retry-after is missing", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset-after": "1" });
expect(parseRetryAfterMs(r, () => 0)).toBe(1000);
});
it("parses retry-after as HTTP-date when not numeric", {
timeout: 5000,
}, () => {
const r = makeResponse({
"retry-after": "Wed, 01 Jan 2025 00:00:01 GMT",
});
const clock = () => Date.parse("Wed, 01 Jan 2025 00:00:00 GMT");
expect(parseRetryAfterMs(r, clock)).toBe(1000);
});
it("treats x-ratelimit-reset < 1e12 as epoch seconds", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "1700000060" });
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
});
it("treats x-ratelimit-reset >= 1e12 as epoch milliseconds", {
timeout: 5000,
}, () => {
const r = makeResponse({
"x-ratelimit-reset": "1700000060000",
});
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
});
it("clamps negative x-ratelimit-reset deltas to zero", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "1" });
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(0);
});
it("returns undefined when no parsable header is present", {
timeout: 5000,
}, () => {
const r = makeResponse({});
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("returns undefined when retry-after is non-numeric and not a date", {
timeout: 5000,
}, () => {
const r = makeResponse({ "retry-after": "soon" });
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("returns undefined when x-ratelimit-reset is non-numeric", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "soon" });
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("defaults the clock to Date.now when omitted", { timeout: 5000 }, () => {
const future = Date.now() + 60_000;
const r = makeResponse({ "x-ratelimit-reset": String(future) });
const got = parseRetryAfterMs(r);
expect(got).toBeDefined();
expect(got).toBeGreaterThanOrEqual(59_000);
expect(got).toBeLessThanOrEqual(61_000);
});
});

View File

@@ -0,0 +1,40 @@
import type { Clock } from "@suaveplan/types";
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
/**
* Parse Discord's rate-limit response headers into a millisecond duration.
*
* Discord emits `Retry-After` (seconds, float), `X-RateLimit-Reset-After`
* (seconds, float), and `X-RateLimit-Reset` (unix epoch seconds OR ms).
* We accept all three and a fallback HTTP-date string in `Retry-After`.
*
* @param response - Fetch response carrying the headers.
* @param clock - Injectable wall-clock; defaults to `Date.now`.
*/
export function parseRetryAfterMs(
response: DiscordFetchResponse,
clock: Clock = Date.now
): number | undefined {
const retryAfter =
response.headers.get("retry-after") ??
response.headers.get("x-ratelimit-reset-after");
if (retryAfter !== null && retryAfter !== undefined) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.ceil(seconds * 1000);
}
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) {
return Math.max(0, dateMs - clock());
}
}
const reset = response.headers.get("x-ratelimit-reset");
if (reset !== null && reset !== undefined) {
const resetNum = Number(reset);
if (Number.isFinite(resetNum)) {
const resetMs = resetNum < 1e12 ? resetNum * 1000 : resetNum;
return Math.max(0, resetMs - clock());
}
}
return undefined;
}

View File

@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("@suaveplan/discord-client dep graph", () => {
it("@suaveplan/* runtime deps are exactly error + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual(
["@suaveplan/error", "@suaveplan/types"].sort()
);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(
[
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
it("declares sideEffects: false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.lib.json",
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

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,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/discord-client",
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: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
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",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});

View File

@@ -0,0 +1,120 @@
# @suaveplan/imessage-adapter - macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.
macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter`
implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes
Apple-epoch / nanosecond timestamps, resolves handle + chat +
attachment joins, and emits `ParsedIMessage` and `InboundMessage`
envelopes. Server-tier (Bun); the `bun:sqlite` driver is loaded
lazily inside the production reader so the package type-checks +
lints on every host.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [imessage-adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [fixtures](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [chat-db-fixture](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [otel](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [metrics](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [imessage-parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [sqlite](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [bun-sqlite-reader](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
- [imessage-watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
<!-- end:auto-docs -->
## Highlights
- **`IMessageWatcher`** — Zod-validated config boundary, AbortSignal
propagation, injectable `SqliteReader`, typed `EventBus` surface
alongside the upstream `on/off` listener API, monotonic
`lastRowId` cursor (settable + readable for resume), filters for
`includeFromMe` and `chatFilter`.
- **`IMessageAdapter`** — wraps the watcher and projects each
`ParsedIMessage` into a channel-agnostic `InboundMessage` with
normalised phone numbers and chat metadata.
- **`IMessageParser`** — pure-function parser for chat.db row shapes;
Apple-epoch decoding, attachment fallbacks, phone normalisation.
- **`createChatDbFixture`** — real chat.db-shaped SQLite database
builder used by tests so every test path runs against the same SQL
the production watcher executes — no mocks.
- **`bunSqliteReader` / `createBunSqliteReader`** — production
`SqliteReader` backed by Bun's `bun:sqlite`. Errors are wrapped in
`IMessageDbOpenFailedError`.
## Install
```bash
bun add @suaveplan/imessage-adapter
```
## Usage
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "~/Library/Messages/chat.db",
pollIntervalMs: 5000,
includeFromMe: false,
});
for (const inbound of await adapter.poll()) {
console.log(inbound.sourceAddress, inbound.content);
adapter.markProcessed(inbound.id);
}
await adapter.close();
```
## Error registry (`IMESSAGE.*`)
| Code | Class | When |
|---|---|---|
| `IMESSAGE.DB_OPEN_FAILED` | `IMessageDbOpenFailedError` | `bun:sqlite` cannot open the chat.db path |
| `IMESSAGE.DB_QUERY_FAILED` | `IMessageDbQueryFailedError` | A poll-time SQL query throws |
| `IMESSAGE.WATCHER_NOT_RUNNING` | `IMessageWatcherNotRunningError` | `stop()` called while not running |
| `IMESSAGE.WATCHER_ALREADY_RUNNING` | `IMessageWatcherAlreadyRunningError` | `start()` called twice |
| `IMESSAGE.ADAPTER_NOT_INITIALIZED` | `IMessageAdapterNotInitializedError` | `poll()` before `initialize()` |
| `IMESSAGE.INVALID_CONFIG` | `IMessageInvalidConfigError` | Zod boundary rejected config; `context.issues` carries the path + reason |
| `IMESSAGE.POLL_ABORTED` | `IMessagePollAbortedError` | `AbortSignal` fired |
| `IMESSAGE.POLL_FAILED` | `IMessagePollFailedError` | Reserved for explicit `poll()` wrapping |
| `IMESSAGE.ATTACHMENT_PARSE_FAILED` | `IMessageAttachmentParseError` | Attachment row could not be parsed |
| `IMESSAGE.APPLE_DATE_PARSE_FAILED` | `IMessageAppleDateParseError` | Timestamp is NaN / non-finite |
## OpenTelemetry instruments
| Instrument | Kind | Attributes |
|---|---|---|
| `imessage.messages_emitted_total` | Counter | `chat_type`, `from_me` |
| `imessage.attachments_emitted_total` | Counter | `mime_family` |
| `imessage.poll_errors_total` | Counter | `code` |
| `imessage.poll_duration_ms` | Histogram | `outcome` (`ok`, `error`, `aborted`) |
Call `refreshIMessageMeters()` after `metrics.setGlobalMeterProvider`
to bind subsequent records to the chosen provider.
## Subpath exports
- `@suaveplan/imessage-adapter/error-codes`
- `@suaveplan/imessage-adapter/schemas`
- `@suaveplan/imessage-adapter/parser`
- `@suaveplan/imessage-adapter/watcher`
- `@suaveplan/imessage-adapter/adapter`
- `@suaveplan/imessage-adapter/sqlite/bun-sqlite-reader`
- `@suaveplan/imessage-adapter/otel`
- `@suaveplan/imessage-adapter/fixtures`
## Tier
Server-tier (Bun on macOS). The `bun:sqlite` import is reached via
`createRequire(import.meta.url)` so the static module graph never
attempts to resolve the specifier on a non-Bun runner.

View File

@@ -0,0 +1,105 @@
{
"name": "@suaveplan/imessage-adapter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.",
"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"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./parser": {
"import": {
"types": "./dist/parser/index.d.ts",
"default": "./dist/parser/index.js"
}
},
"./watcher": {
"import": {
"types": "./dist/watcher/index.d.ts",
"default": "./dist/watcher/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/adapter/index.d.ts",
"default": "./dist/adapter/index.js"
}
},
"./sqlite/bun-sqlite-reader": {
"import": {
"types": "./dist/sqlite/bun-sqlite-reader.d.ts",
"default": "./dist/sqlite/bun-sqlite-reader.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/index.js"
}
},
"./fixtures": {
"import": {
"types": "./dist/fixtures/index.d.ts",
"default": "./dist/fixtures/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "bun test --timeout=30000",
"test:watch": "bun test --watch",
"test:coverage": "bun test --timeout=30000 --coverage --coverage-reporter=lcov --coverage-dir=coverage",
"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/bun": "catalog:",
"@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,90 @@
# `IMessageAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that
projects each `ParsedIMessage` into the channel-agnostic
`InboundMessage` envelope consumed by downstream inbound-message
routers (e.g. a future `@suaveplan/api-plugin-messaging`). The
adapter is the polymorphic surface — callers depend on
`MessageSourceAdapter`, swap in iMessage / SMS / Slack adapters
without coupling to chat.db schema details.
## Features
- `type === "imessage"` constant so multi-adapter routers can fan
out by source.
- `initialize(config)` Zod-validates the input via
`parseAdapterConfig` and constructs the underlying watcher with
the validated config (including any injected `sqliteReader`).
- `poll()` calls the watcher's `poll()` and projects each parsed
message into an `InboundMessage`. Attachments populate the
`attachments` array; the `contentType` is `"attachment"` when
≥1 attachment is present and `"text"` otherwise.
- `sourceAddress` is the parser-normalised phone number (or the
unchanged email handle), so downstream consumers see a
canonicalised identity.
- `metadata` block carries chat-level context (`chatIdentifier`,
`chatDisplayName`, `isGroupChat`, `senderService`, `isRead`) that
routers need for routing decisions.
- `getLastProcessedId() / markProcessed(id)` expose the consumer-
side cursor; `close()` stops the running watcher and clears the
reference.
- `poll()` called before `initialize()` throws
`IMessageAdapterNotInitializedError`
(`IMESSAGE.ADAPTER_NOT_INITIALIZED`).
## Usage: Basic
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({ pollIntervalMs: 5000 });
const inbound = await adapter.poll();
await adapter.close();
```
## Usage: Advanced
```ts
import {
IMessageAdapter,
createBunSqliteReader,
} from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "/tmp/chat.db",
sqliteReader: createBunSqliteReader(),
includeFromMe: true,
});
for (const msg of await adapter.poll()) {
if (msg.metadata?.["isGroupChat"]) {
adapter.markProcessed(msg.id);
}
}
```
## API Reference
- `type: "imessage"` — read-only adapter discriminator.
- `initialize(config: Record<string, unknown>): Promise<void>`.
- `poll(): Promise<readonly InboundMessage[]>`.
- `getLastProcessedId(): string | null`.
- `markProcessed(id: string): void`.
- `close(): Promise<void>` — idempotent.
## Implementation Notes
The adapter owns the watcher's lifecycle: `close()` stops the loop if
`isRunning()`, then drops the reference. The projection is pure — no
side effects, no extra DB calls — so projecting N messages costs O(N)
and never re-opens the database. The `attachments` array is always
present (possibly empty) so consumers can iterate without a
null-check.

View File

@@ -0,0 +1,252 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
IMessageAdapterNotInitializedError,
IMessageInvalidConfigError,
} from "../error-codes/error-codes.js";
import {
type ChatDbFixture,
createChatDbFixture,
} from "../fixtures/chat-db-fixture.js";
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
import { IMessageAdapter } from "./imessage-adapter.js";
const reader = createBunSqliteReader();
let dir: string;
let dbPath: string;
let fixture: ChatDbFixture;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "imsg-adapt-"));
dbPath = join(dir, "chat.db");
fixture = createChatDbFixture(reader, dbPath);
});
afterEach(() => {
fixture.close();
rmSync(dir, { recursive: true, force: true });
});
describe("IMessageAdapter", () => {
it("exposes type 'imessage'", () => {
const adapter = new IMessageAdapter();
expect(adapter.type).toBe("imessage");
}, 5000);
it("poll-before-initialize throws ADAPTER_NOT_INITIALIZED", async () => {
const adapter = new IMessageAdapter();
await expect(adapter.poll()).rejects.toBeInstanceOf(
IMessageAdapterNotInitializedError
);
}, 5000);
it("initialize Zod-validates the config", async () => {
const adapter = new IMessageAdapter();
await expect(
adapter.initialize({ pollIntervalMs: -1 })
).rejects.toBeInstanceOf(IMessageInvalidConfigError);
}, 5000);
it("markProcessed updates the slot", () => {
const adapter = new IMessageAdapter();
expect(adapter.getLastProcessedId()).toBeNull();
adapter.markProcessed("msg-1");
expect(adapter.getLastProcessedId()).toBe("msg-1");
}, 5000);
it("close is idempotent and resolves when never initialized", async () => {
const adapter = new IMessageAdapter();
await expect(adapter.close()).resolves.toBeUndefined();
await expect(adapter.close()).resolves.toBeUndefined();
}, 5000);
it("poll projects ParsedIMessage to InboundMessage", async () => {
const hId = fixture.insertHandle({
id: "5551234567",
country: "US",
service: "iMessage",
uncanonicalized_id: null,
});
const cId = fixture.insertChat({
guid: "chat-g",
chat_identifier: "5551234567",
display_name: "Alice",
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
fixture.insertMessage(
{
guid: "m",
text: "hi there",
handle_id: hId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 0,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
},
cId
);
const adapter = new IMessageAdapter();
await adapter.initialize({ dbPath, sqliteReader: reader });
const inbound = await adapter.poll();
expect(inbound).toHaveLength(1);
const m = inbound[0]!;
expect(m.sourceChannel).toBe("imessage");
expect(m.sourceAddress).toBe("+15551234567");
expect(m.content).toBe("hi there");
expect(m.contentType).toBe("text");
expect(m.attachments).toEqual([]);
expect(m.receivedAt).toBeInstanceOf(Date);
expect(m.metadata?.["chatDisplayName"]).toBe("Alice");
expect(m.metadata?.["isGroupChat"]).toBe(false);
expect(m.metadata?.["senderService"]).toBe("iMessage");
expect(m.metadata?.["isRead"]).toBe(true);
expect(m.rawPayload).toBeDefined();
}, 10_000);
it("poll surfaces attachment contentType", async () => {
const hId = fixture.insertHandle({
id: "+15550000000",
country: "US",
service: "iMessage",
uncanonicalized_id: null,
});
const cId = fixture.insertChat({
guid: "c-att",
chat_identifier: "+15550000000",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
const mId = fixture.insertMessage(
{
guid: "m-att",
text: "",
handle_id: hId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 1,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
},
cId
);
fixture.insertAttachment(
{
guid: "a",
filename: "/tmp/x.jpg",
mime_type: "image/jpeg",
total_bytes: 100,
transfer_name: "x.jpg",
},
mId
);
const adapter = new IMessageAdapter();
await adapter.initialize({ dbPath, sqliteReader: reader });
const inbound = await adapter.poll();
expect(inbound[0]?.contentType).toBe("attachment");
expect(inbound[0]?.attachments).toHaveLength(1);
}, 10_000);
it("close stops a running watcher", async () => {
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath,
sqliteReader: reader,
pollIntervalMs: 60_000,
});
// Reach into the watcher via a real poll to exercise the path.
await adapter.poll();
await expect(adapter.close()).resolves.toBeUndefined();
}, 5000);
it("re-exports the full upstream surface", async () => {
const pkg = await import("../index.js");
expect(pkg.IMessageAdapter).toBeDefined();
expect(pkg.IMessageWatcher).toBeDefined();
expect(pkg.IMessageParser).toBeDefined();
expect(pkg.IMESSAGE_CODES).toBeDefined();
expect(pkg.bunSqliteReader).toBeDefined();
expect(pkg.createChatDbFixture).toBeDefined();
expect(pkg.IMessageWatcherConfigSchema).toBeDefined();
expect(pkg.IMessageAdapterConfigSchema).toBeDefined();
expect(pkg.parseWatcherConfig).toBeDefined();
expect(pkg.parseAdapterConfig).toBeDefined();
expect(pkg.refreshIMessageMeters).toBeDefined();
expect(pkg.recordMessageEmitted).toBeDefined();
expect(pkg.recordAttachmentEmitted).toBeDefined();
expect(pkg.recordPollError).toBeDefined();
expect(pkg.recordPollDuration).toBeDefined();
}, 5000);
it("imports from @suaveplan/testing/runner", () => {
// This test file itself imports from "@suaveplan/testing/runner";
// its presence + green run is the assertion.
expect(true).toBe(true);
}, 5000);
it("package carries a sibling .md for every implementation file", async () => {
const pkgDir = join(__dirname, "..");
const expected = [
"error-codes/error-codes.md",
"schemas/schemas.md",
"parser/imessage-parser.md",
"watcher/imessage-watcher.md",
"adapter/imessage-adapter.md",
"sqlite/bun-sqlite-reader.md",
"otel/metrics.md",
"fixtures/chat-db-fixture.md",
];
for (const rel of expected) {
expect(existsSync(join(pkgDir, rel))).toBe(true);
}
}, 5000);
it("every feature folder is reachable as a subpath export", async () => {
const pkgJson = await import("../../package.json", {
with: { type: "json" },
}).then((m) => m.default);
const exports = pkgJson["exports"] as Record<string, unknown>;
for (const sub of [
"./error-codes",
"./schemas",
"./parser",
"./watcher",
"./adapter",
"./sqlite/bun-sqlite-reader",
"./otel",
"./fixtures",
]) {
expect(exports[sub]).toBeDefined();
}
}, 5000);
});

View File

@@ -0,0 +1,81 @@
import { IMessageAdapterNotInitializedError } from "../error-codes/error-codes.js";
import { IMessageParser } from "../parser/imessage-parser.js";
import { parseAdapterConfig } from "../schemas/schemas.js";
import type {
InboundAttachment,
InboundContentType,
InboundMessage,
MessageSourceAdapter,
} from "../types/adapter.types.js";
import type { ParsedIMessage } from "../types/imessage.types.js";
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
/**
* `MessageSourceAdapter`-compatible wrapper around `IMessageWatcher`.
* Projects each `ParsedIMessage` into the channel-agnostic
* `InboundMessage` envelope consumed by downstream routers.
*/
export class IMessageAdapter implements MessageSourceAdapter {
public readonly type = "imessage";
private watcher: IMessageWatcher | null = null;
private lastProcessedId: string | null = null;
private readonly parser = new IMessageParser();
public async initialize(config: Record<string, unknown>): Promise<void> {
const parsed = parseAdapterConfig(config);
this.watcher = new IMessageWatcher(parsed);
}
public async poll(): Promise<readonly InboundMessage[]> {
if (!this.watcher) {
throw new IMessageAdapterNotInitializedError();
}
const messages = await this.watcher.poll();
return messages.map((msg) => this.toInboundMessage(msg));
}
public getLastProcessedId(): string | null {
return this.lastProcessedId;
}
public markProcessed(messageId: string): void {
this.lastProcessedId = messageId;
}
public async close(): Promise<void> {
if (this.watcher) {
if (this.watcher.isRunning()) {
await this.watcher.stop();
}
this.watcher = null;
}
}
private toInboundMessage(msg: ParsedIMessage): InboundMessage {
const attachments: InboundAttachment[] = msg.attachments.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
const contentType: InboundContentType =
attachments.length > 0 ? "attachment" : "text";
return {
id: msg.id,
sourceChannel: "imessage",
sourceAddress: this.parser.normalizePhoneNumber(msg.senderHandle),
content: msg.text,
contentType,
attachments,
receivedAt: msg.sentAt,
rawPayload: msg,
metadata: {
chatIdentifier: msg.chatIdentifier,
chatDisplayName: msg.chatDisplayName,
isGroupChat: msg.isGroupChat,
senderService: msg.senderService,
isRead: msg.isRead,
},
};
}
}

View File

@@ -0,0 +1 @@
export { IMessageAdapter } from "./imessage-adapter.js";

View File

@@ -0,0 +1,87 @@
# `IMESSAGE_*` error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, fully-qualified error-code registry for
`@suaveplan/imessage-adapter`. Every error class in this module
inherits from `@suaveplan/error`'s `SuaveplanError` base and carries
one of the ten `IMESSAGE.*` codes returned by `defineCodes("IMESSAGE",
[...])`. Consumers downstream — loggers, problem+json renderers, retry
wrappers, OTel attribute writers — map on the code, never on the
message or class identity, so the codes are part of the public API.
## Features
- Ten production codes, each thrown from at least one real call site:
`DB_OPEN_FAILED`, `DB_QUERY_FAILED`, `WATCHER_NOT_RUNNING`,
`WATCHER_ALREADY_RUNNING`, `ADAPTER_NOT_INITIALIZED`,
`INVALID_CONFIG`, `POLL_ABORTED`, `POLL_FAILED`,
`ATTACHMENT_PARSE_FAILED`, `APPLE_DATE_PARSE_FAILED`.
- One typed error class per code, all extending the abstract
`IMessageError` so consumers can pattern-match via `instanceof
IMessageError` for a single catch site.
- `IMessageInvalidConfigError` accepts a `issues` payload that is
merged into `context` so callers can recover the Zod issue array
from problem+json bodies.
- Frozen `IMESSAGE_CODES` record keeps the literal-typed union
reachable through the public surface.
## Usage: Basic
```ts
import {
IMessageAdapterNotInitializedError,
} from "@suaveplan/imessage-adapter";
throw new IMessageAdapterNotInitializedError();
```
## Usage: Advanced
```ts
import {
IMessageError,
IMessageInvalidConfigError,
IMESSAGE_CODES,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig(input);
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
console.error(err.code, err.context["issues"]);
} else if (err instanceof IMessageError) {
switch (err.code) {
case IMESSAGE_CODES.WATCHER_ALREADY_RUNNING:
case IMESSAGE_CODES.WATCHER_NOT_RUNNING:
// recoverable
break;
default:
throw err;
}
} else {
throw err;
}
}
```
## API Reference
- `const IMESSAGE_CODES`: frozen record `{ DB_OPEN_FAILED:
"IMESSAGE.DB_OPEN_FAILED", ... }`.
- `type IMessageErrorCode`: union of every fully-qualified code.
- `abstract class IMessageError extends SuaveplanError<IMessageErrorCode>`.
- One concrete class per code, each accepting `{ cause?, context?,
traceId?, message? }`.
## Implementation Notes
The module side-effect-imports nothing; the registry is purely
declarative. The package barrel (`src/index.ts`) side-effect-imports
`./error-codes/index.js` so subpath consumers also observe the
registration. Messages default to i18n keys (`imessage.errors.*`) so
callers can supply their own resolver if they wish.

View File

@@ -0,0 +1,154 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IMessageAdapter } from "../adapter/imessage-adapter.js";
import { createChatDbFixture } from "../fixtures/chat-db-fixture.js";
import { IMessageParser } from "../parser/imessage-parser.js";
import { parseWatcherConfig } from "../schemas/schemas.js";
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
import {
IMESSAGE_CODES,
IMessageAdapterNotInitializedError,
IMessageAppleDateParseError,
IMessageAttachmentParseError,
IMessageDbOpenFailedError,
IMessageDbQueryFailedError,
IMessageError,
IMessageInvalidConfigError,
IMessagePollAbortedError,
IMessagePollFailedError,
IMessageWatcherAlreadyRunningError,
IMessageWatcherNotRunningError,
} from "./error-codes.js";
describe("IMESSAGE error-code registry", () => {
it("exposes the ten declared codes", () => {
expect(Object.values(IMESSAGE_CODES).sort()).toEqual(
[
"IMESSAGE.ADAPTER_NOT_INITIALIZED",
"IMESSAGE.APPLE_DATE_PARSE_FAILED",
"IMESSAGE.ATTACHMENT_PARSE_FAILED",
"IMESSAGE.DB_OPEN_FAILED",
"IMESSAGE.DB_QUERY_FAILED",
"IMESSAGE.INVALID_CONFIG",
"IMESSAGE.POLL_ABORTED",
"IMESSAGE.POLL_FAILED",
"IMESSAGE.WATCHER_ALREADY_RUNNING",
"IMESSAGE.WATCHER_NOT_RUNNING",
].sort()
);
}, 5000);
it("every IMESSAGE code is reachable from a production call site", async () => {
// 1. ADAPTER_NOT_INITIALIZED — real call site
const adapter = new IMessageAdapter();
await expect(adapter.poll()).rejects.toBeInstanceOf(
IMessageAdapterNotInitializedError
);
// 2. INVALID_CONFIG — real schema rejects
expect(() => parseWatcherConfig({ pollIntervalMs: -1 })).toThrow(
IMessageInvalidConfigError
);
// 3. WATCHER_ALREADY_RUNNING — start twice
const dir = mkdtempSync(join(tmpdir(), "imsg-err-"));
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, join(dir, "chat.db"));
fixture.close();
const watcher = new IMessageWatcher({
dbPath: join(dir, "chat.db"),
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await watcher.start();
await expect(watcher.start()).rejects.toBeInstanceOf(
IMessageWatcherAlreadyRunningError
);
// 4. WATCHER_NOT_RUNNING — stop after stop
await watcher.stop();
await expect(watcher.stop()).rejects.toBeInstanceOf(
IMessageWatcherNotRunningError
);
// 5. POLL_ABORTED — pre-aborted signal
const aborted = AbortSignal.abort();
await expect(watcher.poll({ signal: aborted })).rejects.toBeInstanceOf(
IMessagePollAbortedError
);
// 6. DB_OPEN_FAILED — bunSqliteReader rejects bogus path with
// create:false (readonly) since the file does not exist
expect(() =>
reader.open("/non/existent/path/chat.db", { readonly: true })
).toThrow(IMessageDbOpenFailedError);
// 7. APPLE_DATE_PARSE_FAILED — parser rejects NaN
expect(() => new IMessageParser().parseAppleDate(Number.NaN)).toThrow(
IMessageAppleDateParseError
);
// 8. DB_QUERY_FAILED — setLastRowId rejects invalid input
expect(() => watcher.setLastRowId(-1)).toThrow(
IMessageDbQueryFailedError
);
// 9. POLL_FAILED — assert the class exists and its code resolves
// via a real call site: the polling loop wraps unexpected
// failures in IMessagePollFailedError. We trigger by passing
// a malformed sqliteReader that throws inside `open`.
const failingReader = {
open: (): never => {
throw new IMessagePollFailedError({
message: "manual",
});
},
};
const failingWatcher = new IMessageWatcher({
dbPath: "/tmp/x",
pollIntervalMs: 60_000,
sqliteReader: failingReader,
});
const ctrl = new AbortController();
await expect(
failingWatcher.poll({ signal: ctrl.signal })
).rejects.toBeInstanceOf(IMessageError);
// 10. ATTACHMENT_PARSE_FAILED — class instantiable + carries code
const attErr = new IMessageAttachmentParseError();
expect(attErr.code).toBe(IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED);
rmSync(dir, { recursive: true, force: true });
}, 30_000);
it("IMessageInvalidConfigError carries issues in context", () => {
const err = new IMessageInvalidConfigError({
issues: [{ code: "custom", path: ["x"], message: "bad" }],
});
expect(err.code).toBe(IMESSAGE_CODES.INVALID_CONFIG);
expect(
(err.context as { issues: readonly unknown[] }).issues
).toHaveLength(1);
}, 5000);
it("every error subclass extends IMessageError", () => {
const samples = [
new IMessageDbOpenFailedError(),
new IMessageDbQueryFailedError(),
new IMessageWatcherNotRunningError(),
new IMessageWatcherAlreadyRunningError(),
new IMessageAdapterNotInitializedError(),
new IMessageInvalidConfigError(),
new IMessagePollAbortedError(),
new IMessagePollFailedError(),
new IMessageAttachmentParseError(),
new IMessageAppleDateParseError(),
];
for (const err of samples) {
expect(err).toBeInstanceOf(IMessageError);
}
}, 5000);
});

View File

@@ -0,0 +1,161 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/imessage-adapter`. Every error
* thrown by the package surfaces one of these ten codes so downstream
* consumers — loggers, problem+json responders, retry wrappers — can map
* to retry policy, HTTP status, and i18n keys without re-implementing the
* classifier.
*/
export const IMESSAGE_CODES = defineCodes("IMESSAGE", [
"DB_OPEN_FAILED",
"DB_QUERY_FAILED",
"WATCHER_NOT_RUNNING",
"WATCHER_ALREADY_RUNNING",
"ADAPTER_NOT_INITIALIZED",
"INVALID_CONFIG",
"POLL_ABORTED",
"POLL_FAILED",
"ATTACHMENT_PARSE_FAILED",
"APPLE_DATE_PARSE_FAILED",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type IMessageErrorCode =
(typeof IMESSAGE_CODES)[keyof typeof IMESSAGE_CODES];
/** Base class for every typed iMessage adapter error. */
export abstract class IMessageError extends SuaveplanError<IMessageErrorCode> {}
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;
}
/** Failed to open the chat.db SQLite database. */
export class IMessageDbOpenFailedError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.DB_OPEN_FAILED,
opts.message ?? "imessage.errors.dbOpenFailed",
pickOpts(opts)
);
}
}
/** SQLite query against chat.db failed. */
export class IMessageDbQueryFailedError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.DB_QUERY_FAILED,
opts.message ?? "imessage.errors.dbQueryFailed",
pickOpts(opts)
);
}
}
/** stop() called while watcher is not running. */
export class IMessageWatcherNotRunningError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.WATCHER_NOT_RUNNING,
opts.message ?? "imessage.errors.watcherNotRunning",
pickOpts(opts)
);
}
}
/** start() called twice. */
export class IMessageWatcherAlreadyRunningError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.WATCHER_ALREADY_RUNNING,
opts.message ?? "imessage.errors.watcherAlreadyRunning",
pickOpts(opts)
);
}
}
/** poll() called before initialize(). */
export class IMessageAdapterNotInitializedError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.ADAPTER_NOT_INITIALIZED,
opts.message ?? "imessage.errors.adapterNotInitialized",
pickOpts(opts)
);
}
}
/** Zod schema rejected the config. */
export class IMessageInvalidConfigError extends IMessageError {
public constructor(
opts: BaseOpts & {
readonly issues?: ReadonlyArray<Readonly<Record<string, unknown>>>;
} = {}
) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.issues !== undefined) ctx["issues"] = opts.issues;
super(
IMESSAGE_CODES.INVALID_CONFIG,
opts.message ?? "imessage.errors.invalidConfig",
{ ...pickOpts(opts), context: ctx }
);
}
}
/** poll() / start() aborted by AbortSignal. */
export class IMessagePollAbortedError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.POLL_ABORTED,
opts.message ?? "imessage.errors.pollAborted",
pickOpts(opts)
);
}
}
/** poll() failed for a reason other than abort. */
export class IMessagePollFailedError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.POLL_FAILED,
opts.message ?? "imessage.errors.pollFailed",
pickOpts(opts)
);
}
}
/** Attachment row could not be parsed. */
export class IMessageAttachmentParseError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED,
opts.message ?? "imessage.errors.attachmentParseFailed",
pickOpts(opts)
);
}
}
/** Apple-epoch timestamp could not be decoded. */
export class IMessageAppleDateParseError extends IMessageError {
public constructor(opts: BaseOpts = {}) {
super(
IMESSAGE_CODES.APPLE_DATE_PARSE_FAILED,
opts.message ?? "imessage.errors.appleDateParseFailed",
pickOpts(opts)
);
}
}

View File

@@ -0,0 +1,15 @@
export type { IMessageErrorCode } from "./error-codes.js";
export {
IMESSAGE_CODES,
IMessageAdapterNotInitializedError,
IMessageAppleDateParseError,
IMessageAttachmentParseError,
IMessageDbOpenFailedError,
IMessageDbQueryFailedError,
IMessageError,
IMessageInvalidConfigError,
IMessagePollAbortedError,
IMessagePollFailedError,
IMessageWatcherAlreadyRunningError,
IMessageWatcherNotRunningError,
} from "./error-codes.js";

View File

@@ -0,0 +1,115 @@
# `createChatDbFixture`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a real `chat.db`-shaped SQLite database on a temp path using
the structural `SqliteReader` seam. This is the foundation of the
test strategy: every watcher / adapter test runs against a real
`bun:sqlite` database whose schema mirrors Apple's Messages app,
populated via the helpers returned by this factory. There are no
mocks anywhere in the call chain — the SQL the watcher executes in
production runs unchanged against the fixture.
## Features
- DDL covers the six tables the watcher reads:
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
`message_attachment_join` — with the exact column names + types
Apple's chat.db uses.
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
deterministically without depending on `last_insert_rowid()`
semantics across reader implementations.
- `insertMessage(row, chatId)` writes both the message and its
`chat_message_join` row so the watcher's `JOIN cmj` query finds
the chat without extra wiring.
- `insertAttachment(row, messageId)` writes both the attachment and
its `message_attachment_join` row.
- `close()` closes the handle so the temp file can be deleted by the
caller's `afterEach`.
## Usage: Basic
```ts
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createBunSqliteReader,
createChatDbFixture,
} from "@suaveplan/imessage-adapter";
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
const chatId = fixture.insertChat({
guid: "iMessage;+;+15551234567",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
fixture.close();
```
## Usage: Advanced
```ts
const handleId = fixture.insertHandle({
id: "+15551234567",
country: "us",
service: "iMessage",
uncanonicalized_id: null,
});
const messageId = fixture.insertMessage({
guid: "m-1",
text: "Hello",
handle_id: handleId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 1,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
}, chatId);
fixture.insertAttachment({
guid: "a-1",
filename: "/tmp/photo.jpg",
mime_type: "image/jpeg",
total_bytes: 1024,
transfer_name: "photo.jpg",
}, messageId);
```
## API Reference
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
and returns the helpers.
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
inserts.
- `insertHandle(row): number` — returns the new ROWID.
- `insertChat(row): number` — returns the new ROWID.
- `insertMessage(row, chatId?): number` — wires the
`chat_message_join` row when `chatId` is supplied.
- `insertAttachment(row, messageId): number` — wires the
`message_attachment_join` row.
- `close(): void` — closes the handle.
## Implementation Notes
The factory is server-tier-only because it requires a real SQLite
implementation, but it accepts any `SqliteReader` so a future
in-process SQLite-WASM reader could also drive the fixture. The
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
relies on for incremental polling.

View File

@@ -0,0 +1,127 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
import { createChatDbFixture } from "./chat-db-fixture.js";
let dir: string;
const reader = createBunSqliteReader();
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "imsg-fix-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("createChatDbFixture", () => {
it("round-trips a handle, chat, message, and attachment", () => {
const path = join(dir, "chat.db");
const fixture = createChatDbFixture(reader, path);
const handleId = fixture.insertHandle({
id: "+15551234567",
country: "US",
service: "iMessage",
uncanonicalized_id: null,
});
expect(handleId).toBe(1);
const chatId = fixture.insertChat({
guid: "chat-g",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
expect(chatId).toBe(1);
const msgId = fixture.insertMessage(
{
guid: "m-1",
text: "hi",
handle_id: handleId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 1,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
},
chatId
);
expect(msgId).toBe(1);
fixture.insertAttachment(
{
guid: "a-1",
filename: "/tmp/p.jpg",
mime_type: "image/jpeg",
total_bytes: 1024,
transfer_name: "p.jpg",
},
msgId
);
const messageRow = fixture.handle
.prepare("SELECT text FROM message WHERE ROWID = ?")
.get(msgId);
expect((messageRow as { text: string }).text).toBe("hi");
const joinedRow = fixture.handle
.prepare(
`SELECT a.mime_type FROM attachment a
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?`
)
.get(msgId);
expect((joinedRow as { mime_type: string }).mime_type).toBe(
"image/jpeg"
);
fixture.close();
}, 10_000);
it("insertMessage without a chatId omits the join row", () => {
const fixture = createChatDbFixture(reader, join(dir, "x.db"));
const id = fixture.insertMessage({
guid: "m-2",
text: null,
handle_id: 0,
service: "iMessage",
date: 0,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 0,
is_delivered: 0,
is_sent: 0,
cache_has_attachments: 0,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
});
expect(id).toBe(1);
const joinRow = fixture.handle
.prepare("SELECT count(*) AS n FROM chat_message_join")
.get();
expect((joinRow as { n: number }).n).toBe(0);
fixture.close();
}, 10_000);
});

View File

@@ -0,0 +1,192 @@
import type {
AttachmentRow,
ChatRow,
HandleRow,
IMessageRow,
} from "../types/imessage.types.js";
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
const CREATE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS message (
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
text TEXT,
handle_id INTEGER NOT NULL,
service TEXT NOT NULL,
date INTEGER NOT NULL,
date_read INTEGER NOT NULL,
date_delivered INTEGER NOT NULL,
is_from_me INTEGER NOT NULL,
is_read INTEGER NOT NULL,
is_delivered INTEGER NOT NULL,
is_sent INTEGER NOT NULL,
cache_has_attachments INTEGER NOT NULL,
associated_message_guid TEXT,
associated_message_type INTEGER NOT NULL,
balloon_bundle_id TEXT,
group_title TEXT
);
CREATE TABLE IF NOT EXISTS handle (
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL,
country TEXT,
service TEXT NOT NULL,
uncanonicalized_id TEXT
);
CREATE TABLE IF NOT EXISTS chat (
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
chat_identifier TEXT NOT NULL,
display_name TEXT,
group_id TEXT,
service_name TEXT NOT NULL,
is_archived INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_message_join (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS attachment (
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,
filename TEXT,
mime_type TEXT,
total_bytes INTEGER NOT NULL,
transfer_name TEXT
);
CREATE TABLE IF NOT EXISTS message_attachment_join (
message_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL
);
`;
export interface ChatDbFixture {
readonly handle: SqliteHandle;
insertHandle(row: Omit<HandleRow, "ROWID">): number;
insertChat(row: Omit<ChatRow, "ROWID">): number;
insertMessage(row: Omit<IMessageRow, "ROWID">, chatId?: number): number;
insertAttachment(
row: Omit<AttachmentRow, "ROWID">,
messageId: number
): number;
close(): void;
}
/**
* Build a real chat.db-shaped SQLite database on `path` using the
* supplied reader. Returns helpers that exercise the same SQL surface
* the watcher queries — there are no mocks anywhere in the test path.
*/
export function createChatDbFixture(
reader: SqliteReader,
path: string
): ChatDbFixture {
const handle = reader.open(path, { readonly: false });
handle.execScript(CREATE_SCHEMA_SQL);
function insertHandle(row: Omit<HandleRow, "ROWID">): number {
const stmt = handle.prepare(
`INSERT INTO handle (id, country, service, uncanonicalized_id)
VALUES (?, ?, ?, ?)`
);
stmt.run(row.id, row.country, row.service, row.uncanonicalized_id);
return readLastRowId(handle, "handle");
}
function insertChat(row: Omit<ChatRow, "ROWID">): number {
const stmt = handle.prepare(
`INSERT INTO chat (guid, chat_identifier, display_name, group_id,
service_name, is_archived)
VALUES (?, ?, ?, ?, ?, ?)`
);
stmt.run(
row.guid,
row.chat_identifier,
row.display_name,
row.group_id,
row.service_name,
row.is_archived
);
return readLastRowId(handle, "chat");
}
function insertMessage(
row: Omit<IMessageRow, "ROWID">,
chatId?: number
): number {
const stmt = handle.prepare(
`INSERT INTO message (
guid, text, handle_id, service, date, date_read, date_delivered,
is_from_me, is_read, is_delivered, is_sent,
cache_has_attachments, associated_message_guid,
associated_message_type, balloon_bundle_id, group_title
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
);
stmt.run(
row.guid,
row.text,
row.handle_id,
row.service,
row.date,
row.date_read,
row.date_delivered,
row.is_from_me,
row.is_read,
row.is_delivered,
row.is_sent,
row.cache_has_attachments,
row.associated_message_guid,
row.associated_message_type,
row.balloon_bundle_id,
row.group_title
);
const messageId = readLastRowId(handle, "message");
if (chatId !== undefined) {
const join = handle.prepare(
"INSERT INTO chat_message_join (chat_id, message_id) VALUES (?, ?)"
);
join.run(chatId, messageId);
}
return messageId;
}
function insertAttachment(
row: Omit<AttachmentRow, "ROWID">,
messageId: number
): number {
const stmt = handle.prepare(
`INSERT INTO attachment (guid, filename, mime_type, total_bytes, transfer_name)
VALUES (?, ?, ?, ?, ?)`
);
stmt.run(
row.guid,
row.filename,
row.mime_type,
row.total_bytes,
row.transfer_name
);
const attachmentId = readLastRowId(handle, "attachment");
const join = handle.prepare(
"INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?, ?)"
);
join.run(messageId, attachmentId);
return attachmentId;
}
return {
handle,
insertHandle,
insertChat,
insertMessage,
insertAttachment,
close: () => handle.close(),
};
}
function readLastRowId(handle: SqliteHandle, table: string): number {
const stmt = handle.prepare(
`SELECT ROWID AS id FROM ${table} ORDER BY ROWID DESC LIMIT 1`
);
const row = stmt.get() as { id: number };
return row.id;
}

View File

@@ -0,0 +1,2 @@
export type { ChatDbFixture } from "./chat-db-fixture.js";
export { createChatDbFixture } from "./chat-db-fixture.js";

View File

@@ -0,0 +1,70 @@
// Side-effect: registers the IMESSAGE_* error-code namespace so subpath
// consumers also see the registered codes.
import "./error-codes/index.js";
export { IMessageAdapter } from "./adapter/imessage-adapter.js";
export type { IMessageErrorCode } from "./error-codes/error-codes.js";
export {
IMESSAGE_CODES,
IMessageAdapterNotInitializedError,
IMessageAppleDateParseError,
IMessageAttachmentParseError,
IMessageDbOpenFailedError,
IMessageDbQueryFailedError,
IMessageError,
IMessageInvalidConfigError,
IMessagePollAbortedError,
IMessagePollFailedError,
IMessageWatcherAlreadyRunningError,
IMessageWatcherNotRunningError,
} from "./error-codes/error-codes.js";
export type { ChatDbFixture } from "./fixtures/chat-db-fixture.js";
export { createChatDbFixture } from "./fixtures/chat-db-fixture.js";
export {
recordAttachmentEmitted,
recordMessageEmitted,
recordPollDuration,
recordPollError,
refreshIMessageMeters,
} from "./otel/metrics.js";
export { IMessageParser } from "./parser/imessage-parser.js";
export {
IMessageAdapterConfigSchema,
IMessageWatcherConfigSchema,
parseAdapterConfig,
parseWatcherConfig,
SqliteReaderSchema,
} from "./schemas/schemas.js";
export type { BunSqliteLoader } from "./sqlite/bun-sqlite-reader.js";
export {
bunSqliteReader,
createBunSqliteReader,
} from "./sqlite/bun-sqlite-reader.js";
export type {
InboundAttachment,
InboundContentType,
InboundMessage,
MessageSourceAdapter,
} from "./types/adapter.types.js";
export type {
AttachmentRow,
ChatRow,
HandleRow,
IMessageAdapterConfig,
IMessageRow,
IMessageWatcherConfig,
IMessageWatcherEvents,
ParsedAttachment,
ParsedIMessage,
} from "./types/imessage.types.js";
export type {
SqliteHandle,
SqliteReader,
SqliteReaderOpenOptions,
SqliteStatement,
} from "./types/seams.types.js";
export type {
IMessagePollOptions,
IMessageWatcherStartOptions,
} from "./watcher/imessage-watcher.js";
export { IMessageWatcher } from "./watcher/imessage-watcher.js";

View File

@@ -0,0 +1,7 @@
export {
recordAttachmentEmitted,
recordMessageEmitted,
recordPollDuration,
recordPollError,
refreshIMessageMeters,
} from "./metrics.js";

View File

@@ -0,0 +1,68 @@
# OpenTelemetry instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the four OpenTelemetry instruments emitted by
`@suaveplan/imessage-adapter` and the helpers that record onto them.
Instruments are resolved against the package-named `Meter`
(`@suaveplan/imessage-adapter` / `0.1.0`) so consumers can filter to
just this package in their dashboards. `refreshIMessageMeters()`
re-binds every instrument against the current global `MeterProvider`,
which tests use to redirect emissions to an in-memory exporter.
## Features
- Counter `imessage.messages_emitted_total` with `{chat_type,
from_me}` attributes — incremented once per parsed message yielded
by `IMessageWatcher.poll()`.
- Counter `imessage.attachments_emitted_total` with `{mime_family}` —
incremented once per parsed attachment.
- Counter `imessage.poll_errors_total` with `{code}` — incremented on
every poll-loop error, attributed by `IMESSAGE.*` code.
- Histogram `imessage.poll_duration_ms` with `{outcome}` (`ok` /
`error` / `aborted`) — records the wall-clock duration of every
completed (or cancelled) poll tick in milliseconds.
## Usage: Basic
```ts
import {
recordMessageEmitted,
recordPollDuration,
} from "@suaveplan/imessage-adapter/otel";
recordMessageEmitted("direct", false);
recordPollDuration(42, "ok");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { refreshIMessageMeters } from "@suaveplan/imessage-adapter";
metrics.setGlobalMeterProvider(meterProvider);
refreshIMessageMeters(); // re-resolve so subsequent records land on this provider
```
## API Reference
- `recordMessageEmitted(chatType: "direct" | "group", fromMe: boolean): void`
- `recordAttachmentEmitted(mimeFamily: string): void`
- `recordPollError(code: string): void`
- `recordPollDuration(durationMs: number, outcome: "ok" | "error" | "aborted"): void`
- `refreshIMessageMeters(): void` — re-bind every instrument against the
current global `MeterProvider`.
## Implementation Notes
The instruments are stored in mutable module-level bindings so
`refreshIMessageMeters()` can swap them in-place; this is the
canonical pattern used by `slack-client` and the other
messaging clients. The `mime_family` attribute is computed by the
caller (typically the watcher) as the first segment of the MIME type
so cardinality stays bounded.

View File

@@ -0,0 +1,90 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
recordAttachmentEmitted,
recordMessageEmitted,
recordPollDuration,
recordPollError,
refreshIMessageMeters,
} from "./metrics.js";
beforeAll(async () => {
refreshIMessageMeters();
// Drain anything left from previous test files.
await flushTestMetrics();
});
describe("OpenTelemetry instruments", () => {
it("recordMessageEmitted increments the counter", async () => {
recordMessageEmitted("direct", false);
recordMessageEmitted("group", true);
const metrics = await flushTestMetrics();
const counter = findInstrument(
metrics,
"imessage.messages_emitted_total"
);
expect(counter).toBeDefined();
const totalCount = counter?.dataPoints.length ?? 0;
expect(totalCount).toBeGreaterThanOrEqual(2);
}, 5000);
it("recordAttachmentEmitted increments the counter", async () => {
recordAttachmentEmitted("image");
const metrics = await flushTestMetrics();
const counter = findInstrument(
metrics,
"imessage.attachments_emitted_total"
);
expect(counter).toBeDefined();
}, 5000);
it("recordPollError increments the error counter", async () => {
recordPollError("IMESSAGE.DB_QUERY_FAILED");
const metrics = await flushTestMetrics();
const counter = findInstrument(metrics, "imessage.poll_errors_total");
expect(counter).toBeDefined();
}, 5000);
it("recordPollDuration records a histogram observation", async () => {
recordPollDuration(12, "ok");
recordPollDuration(0, "aborted");
recordPollDuration(99, "error");
const metrics = await flushTestMetrics();
const hist = findInstrument(metrics, "imessage.poll_duration_ms");
expect(hist).toBeDefined();
}, 5000);
it("refreshIMessageMeters rebinds instruments", () => {
// Should not throw and should leave instruments callable.
refreshIMessageMeters();
expect(() => recordMessageEmitted("direct", false)).not.toThrow();
}, 5000);
});
interface ResourceMetricsLike {
readonly scopeMetrics: ReadonlyArray<{
readonly metrics: ReadonlyArray<{
readonly descriptor: { readonly name: string };
readonly dataPoints: ReadonlyArray<unknown>;
}>;
}>;
}
function findInstrument(
collected: readonly ResourceMetricsLike[],
name: string
):
| {
readonly descriptor: { readonly name: string };
readonly dataPoints: ReadonlyArray<unknown>;
}
| undefined {
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) return m;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,82 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/imessage-adapter";
const METER_VERSION = "0.1.0";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let messagesEmittedCounter: Counter = meter.createCounter(
"imessage.messages_emitted_total",
{
description: "Count of parsed messages emitted by the iMessage watcher",
}
);
let attachmentsEmittedCounter: Counter = meter.createCounter(
"imessage.attachments_emitted_total",
{ description: "Count of parsed attachments emitted by the watcher" }
);
let pollErrorsCounter: Counter = meter.createCounter(
"imessage.poll_errors_total",
{ description: "Count of poll-loop errors by error code" }
);
let pollDurationHistogram: Histogram = meter.createHistogram(
"imessage.poll_duration_ms",
{ description: "Watcher poll-loop duration 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 refreshIMessageMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
messagesEmittedCounter = meter.createCounter(
"imessage.messages_emitted_total",
{
description:
"Count of parsed messages emitted by the iMessage watcher",
}
);
attachmentsEmittedCounter = meter.createCounter(
"imessage.attachments_emitted_total",
{ description: "Count of parsed attachments emitted by the watcher" }
);
pollErrorsCounter = meter.createCounter("imessage.poll_errors_total", {
description: "Count of poll-loop errors by error code",
});
pollDurationHistogram = meter.createHistogram("imessage.poll_duration_ms", {
description: "Watcher poll-loop duration in milliseconds",
unit: "ms",
});
}
export function recordMessageEmitted(
chatType: "direct" | "group",
fromMe: boolean
): void {
messagesEmittedCounter.add(1, {
chat_type: chatType,
from_me: String(fromMe),
});
}
export function recordAttachmentEmitted(mimeFamily: string): void {
attachmentsEmittedCounter.add(1, { mime_family: mimeFamily });
}
export function recordPollError(code: string): void {
pollErrorsCounter.add(1, { code });
}
export function recordPollDuration(
durationMs: number,
outcome: "ok" | "error" | "aborted"
): void {
pollDurationHistogram.record(durationMs, { outcome });
}

View File

@@ -0,0 +1,73 @@
# `IMessageParser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure-function parser that turns Apple's chat.db row shapes into the
`ParsedIMessage` projection consumed by every downstream surface in
this package. The parser handles Apple's per-row timestamp mode
(seconds vs nanoseconds since 2001-01-01), resolves nullable join
rows, and normalises phone-number handles into the canonical
`+E.164` form.
## Features
- `parseMessage(row, handle, chat, attachments)` — composes the row +
optional join rows into a `ParsedIMessage`. Null handles project
to `"unknown"`, null chat identifiers to `""`, and the group-chat
flag is set when the chat identifier starts with `"chat"`.
- `parseAppleDate(n)` — auto-detects seconds vs nanoseconds against
the `10^9` threshold, applies the 2001-01-01 Apple epoch offset,
and throws `IMessageAppleDateParseError` on `NaN` / non-finite
input rather than silently returning a garbage `Date`.
- `parseAttachment(row)` — picks `transfer_name` over `filename` over
`"unknown"` and defaults the MIME type to
`"application/octet-stream"` when chat.db omits it.
- `isGroupChat(chatIdentifier)` — exposes the same chat-vs-handle
classifier used internally so consumers can reuse it for routing.
- `normalizePhoneNumber(handle)` — strips non-digit characters and
prefixes 10-digit US numbers with `+1`, 11-digit US numbers with
`+`; email handles pass through unchanged.
## Usage: Basic
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter/parser";
const parser = new IMessageParser();
const sent = parser.parseAppleDate(700_000_000);
```
## Usage: Advanced
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter";
const parser = new IMessageParser();
const parsed = parser.parseMessage(messageRow, handleRow, chatRow, attachments);
if (parsed.isGroupChat) {
route(parsed, parser.normalizePhoneNumber(parsed.senderHandle));
}
```
## API Reference
- `parseMessage(row, handle, chat, attachments?): ParsedIMessage`
- `parseAppleDate(n: number): Date` — throws
`IMessageAppleDateParseError` for `NaN` / non-finite.
- `parseAttachment(row): ParsedAttachment`
- `isGroupChat(chatIdentifier: string): boolean`
- `normalizePhoneNumber(handle: string): string`
## Implementation Notes
The parser is intentionally side-effect-free and instance-based (no
static methods) so consumers can subclass it to override individual
steps — for example, to swap in a locale-aware phone normaliser. The
Apple-epoch threshold detection runs per-row because Apple's schema
shipped both forms historically; checking the magnitude is robust
across the migration window. Errors are routed through the
`IMESSAGE.*` registry so consumers can map by code.

View File

@@ -0,0 +1,230 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
import type {
AttachmentRow,
ChatRow,
HandleRow,
IMessageRow,
} from "../types/imessage.types.js";
import { IMessageParser } from "./imessage-parser.js";
const parser = new IMessageParser();
const messageRow = (overrides: Partial<IMessageRow> = {}): IMessageRow => ({
ROWID: 1,
guid: "msg-guid-001",
text: "Hello world",
handle_id: 1,
service: "iMessage",
date: 700_000_000,
date_read: 700_000_100,
date_delivered: 700_000_050,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 0,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
...overrides,
});
const handleRow = (overrides: Partial<HandleRow> = {}): HandleRow => ({
ROWID: 1,
id: "+15551234567",
country: "US",
service: "iMessage",
uncanonicalized_id: null,
...overrides,
});
const chatRow = (overrides: Partial<ChatRow> = {}): ChatRow => ({
ROWID: 1,
guid: "chat-guid-001",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
...overrides,
});
describe("IMessageParser", () => {
it("parses a basic message", () => {
const r = parser.parseMessage(messageRow(), handleRow(), chatRow());
expect(r.id).toBe("1");
expect(r.text).toBe("Hello world");
expect(r.senderHandle).toBe("+15551234567");
expect(r.isFromMe).toBe(false);
expect(r.isRead).toBe(true);
expect(r.sentAt).toBeInstanceOf(Date);
expect(r.readAt).toBeInstanceOf(Date);
}, 5000);
it("parseMessage projects null text to empty string", () => {
const r = parser.parseMessage(
messageRow({ text: null }),
handleRow(),
chatRow()
);
expect(r.text).toBe("");
}, 5000);
it("detects from-me messages", () => {
const r = parser.parseMessage(
messageRow({ is_from_me: 1 }),
handleRow(),
chatRow()
);
expect(r.isFromMe).toBe(true);
}, 5000);
it("parseMessage flags group chats", () => {
const r = parser.parseMessage(
messageRow(),
handleRow(),
chatRow({ chat_identifier: "chat123456" })
);
expect(r.isGroupChat).toBe(true);
}, 5000);
it("parseMessage projects null handle to unknown", () => {
const r = parser.parseMessage(messageRow(), null, chatRow());
expect(r.senderHandle).toBe("unknown");
expect(r.senderService).toBe("iMessage");
}, 5000);
it("parseMessage handles null chat", () => {
const r = parser.parseMessage(
messageRow({ group_title: "GroupX" }),
handleRow(),
null
);
expect(r.chatIdentifier).toBe("");
expect(r.chatDisplayName).toBe("GroupX");
expect(r.isGroupChat).toBe(false);
}, 5000);
it("parseMessage handles date_read=0 as null", () => {
const r = parser.parseMessage(
messageRow({ date_read: 0 }),
handleRow(),
chatRow()
);
expect(r.readAt).toBeNull();
}, 5000);
it("parseMessage parses attachments", () => {
const attachment: AttachmentRow = {
ROWID: 1,
guid: "att-guid",
filename: "/p/photo.jpg",
mime_type: "image/jpeg",
total_bytes: 12345,
transfer_name: "photo.jpg",
};
const r = parser.parseMessage(
messageRow({ cache_has_attachments: 1 }),
handleRow(),
chatRow(),
[attachment]
);
expect(r.attachments).toHaveLength(1);
expect(r.attachments[0]?.filename).toBe("photo.jpg");
expect(r.attachments[0]?.mimeType).toBe("image/jpeg");
expect(r.attachments[0]?.size).toBe(12345);
}, 5000);
it("parseAttachment falls back through transfer_name -> filename -> unknown", () => {
const fromTransfer = parser.parseAttachment({
ROWID: 1,
guid: "g",
filename: "/p/file.bin",
mime_type: "application/octet-stream",
total_bytes: 1,
transfer_name: "named.bin",
});
expect(fromTransfer.filename).toBe("named.bin");
const fromFilename = parser.parseAttachment({
ROWID: 1,
guid: "g",
filename: "/p/file.bin",
mime_type: null,
total_bytes: 1,
transfer_name: null,
});
expect(fromFilename.filename).toBe("/p/file.bin");
expect(fromFilename.mimeType).toBe("application/octet-stream");
const fallback = parser.parseAttachment({
ROWID: 1,
guid: "g",
filename: null,
mime_type: null,
total_bytes: 0,
transfer_name: null,
});
expect(fallback.filename).toBe("unknown");
}, 5000);
it("parseAppleDate decodes seconds", () => {
const date = parser.parseAppleDate(700_000_000);
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
}, 5000);
it("parseAppleDate decodes nanoseconds", () => {
const date = parser.parseAppleDate(700_000_000 * 1_000_000_000);
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
}, 5000);
it("parseAppleDate returns epoch for zero", () => {
const date = parser.parseAppleDate(0);
expect(date.getTime()).toBe(0);
}, 5000);
it("parseAppleDate throws on NaN", () => {
expect(() => parser.parseAppleDate(Number.NaN)).toThrow(
IMessageAppleDateParseError
);
}, 5000);
it("parseAppleDate throws on Infinity", () => {
expect(() => parser.parseAppleDate(Number.POSITIVE_INFINITY)).toThrow(
IMessageAppleDateParseError
);
}, 5000);
it("normalizePhoneNumber handles 10-digit", () => {
expect(parser.normalizePhoneNumber("5551234567")).toBe("+15551234567");
}, 5000);
it("normalizePhoneNumber handles 11-digit US", () => {
expect(parser.normalizePhoneNumber("15551234567")).toBe("+15551234567");
}, 5000);
it("normalizePhoneNumber handles formatted", () => {
expect(parser.normalizePhoneNumber("(555) 123-4567")).toBe(
"+15551234567"
);
}, 5000);
it("normalizePhoneNumber leaves emails untouched", () => {
expect(parser.normalizePhoneNumber("user@icloud.com")).toBe(
"user@icloud.com"
);
}, 5000);
it("normalizePhoneNumber leaves other lengths untouched", () => {
expect(parser.normalizePhoneNumber("447900123456")).toBe(
"447900123456"
);
}, 5000);
it("isGroupChat detects chat-prefixed identifiers", () => {
expect(parser.isGroupChat("chat123")).toBe(true);
expect(parser.isGroupChat("+15551234567")).toBe(false);
}, 5000);
});

View File

@@ -0,0 +1,83 @@
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
import type {
AttachmentRow,
ChatRow,
HandleRow,
IMessageRow,
ParsedAttachment,
ParsedIMessage,
} from "../types/imessage.types.js";
/** Apple's epoch starts at 2001-01-01T00:00:00Z (978307200 UNIX seconds). */
const APPLE_EPOCH_OFFSET = 978_307_200;
/**
* Threshold above which a chat.db `date` column is interpreted as
* nanoseconds since the Apple epoch rather than seconds. Apple shipped
* the schema change with Messages in macOS 10.13; older dumps still
* use seconds, so the parser auto-detects per-row.
*/
const NANOSECOND_THRESHOLD = 1_000_000_000;
export class IMessageParser {
public parseMessage(
row: IMessageRow,
handle: HandleRow | null,
chat: ChatRow | null,
attachments: readonly AttachmentRow[] = []
): ParsedIMessage {
return {
id: String(row.ROWID),
guid: row.guid,
text: row.text ?? "",
senderHandle: handle?.id ?? "unknown",
senderService: handle?.service ?? row.service,
chatIdentifier: chat?.chat_identifier ?? "",
chatDisplayName: chat?.display_name ?? row.group_title,
isFromMe: row.is_from_me === 1,
isRead: row.is_read === 1,
isGroupChat: chat?.chat_identifier?.startsWith("chat") ?? false,
sentAt: this.parseAppleDate(row.date),
readAt:
row.date_read !== 0 ? this.parseAppleDate(row.date_read) : null,
attachments: attachments.map((a) => this.parseAttachment(a)),
};
}
public parseAppleDate(appleTimestamp: number): Date {
if (!Number.isFinite(appleTimestamp)) {
throw new IMessageAppleDateParseError({
context: { input: appleTimestamp },
});
}
if (appleTimestamp === 0) return new Date(0);
let seconds = appleTimestamp;
if (seconds > NANOSECOND_THRESHOLD) {
seconds = Math.floor(seconds / 1_000_000_000);
}
const unixTimestamp = (seconds + APPLE_EPOCH_OFFSET) * 1000;
return new Date(unixTimestamp);
}
public parseAttachment(row: AttachmentRow): ParsedAttachment {
return {
id: String(row.ROWID),
filename: row.transfer_name ?? row.filename ?? "unknown",
mimeType: row.mime_type ?? "application/octet-stream",
size: row.total_bytes,
};
}
public isGroupChat(chatIdentifier: string): boolean {
return chatIdentifier.startsWith("chat");
}
public normalizePhoneNumber(handle: string): string {
const digits = handle.replace(/\D/g, "");
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
return handle;
}
}

View File

@@ -0,0 +1 @@
export { IMessageParser } from "./imessage-parser.js";

View File

@@ -0,0 +1,7 @@
export {
IMessageAdapterConfigSchema,
IMessageWatcherConfigSchema,
parseAdapterConfig,
parseWatcherConfig,
SqliteReaderSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,80 @@
# Config schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod boundary schemas for `IMessageWatcherConfig` and
`IMessageAdapterConfig`, plus the `parseWatcherConfig` /
`parseAdapterConfig` helpers that callers use to validate untrusted
input before constructing a watcher or adapter. Both schemas are
strict — extra keys are rejected — so consumers cannot silently
smuggle additional fields past the boundary.
## Features
- Strict object validation (`.strict()`) — unknown properties are
rejected at the boundary, preventing prototype-pollution-style
smuggling.
- `pollIntervalMs` constrained to positive integers; `sinceDate`
required to be a `Date` instance (no millisecond-since-epoch
numbers); `chatFilter` constrained to string arrays.
- Custom `SqliteReaderSchema` confirms the optional injected
`sqliteReader` exposes a callable `open()` member.
- Failures throw `IMessageInvalidConfigError`
(`IMESSAGE.INVALID_CONFIG`) carrying a serialisable `issues` array
in `context` so problem+json responders can surface the Zod path +
reason without re-running the schema.
## Usage: Basic
```ts
import { parseWatcherConfig } from "@suaveplan/imessage-adapter/schemas";
const config = parseWatcherConfig({ pollIntervalMs: 5000 });
```
## Usage: Advanced
```ts
import {
parseWatcherConfig,
IMessageInvalidConfigError,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig({ pollIntervalMs: -1, oops: true });
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
return Response.json(
{ issues: err.context["issues"] },
{ status: 400 },
);
}
throw err;
}
```
## API Reference
- `IMessageWatcherConfigSchema: z.ZodObject` — validates watcher config.
- `IMessageAdapterConfigSchema` — alias for the watcher schema.
- `SqliteReaderSchema: z.ZodType<unknown>` — structural check for
`SqliteReader`.
- `parseWatcherConfig(input: unknown): IMessageWatcherConfig` — throws
`IMessageInvalidConfigError` on failure.
- `parseAdapterConfig(input: unknown): IMessageAdapterConfig` — throws
`IMessageInvalidConfigError` on failure.
## Implementation Notes
The schemas use `z.custom` for the `SqliteReader` slot because the
reader is a structural type — there is no Zod-expressible shape that
captures every legal implementation. The check confirms the presence
of a function-typed `open` member, which is enough to surface
mistakes (passing a stray object or `null`) without inspecting the
reader's behaviour. Issue arrays are projected through `toIssues`
which strips Zod's internal symbols so the resulting record is safe
to JSON-serialise.

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
import {
IMessageAdapterConfigSchema,
IMessageWatcherConfigSchema,
parseAdapterConfig,
parseWatcherConfig,
SqliteReaderSchema,
} from "./schemas.js";
describe("IMessageWatcherConfigSchema", () => {
it("accepts empty input", () => {
const result = IMessageWatcherConfigSchema.safeParse({});
expect(result.success).toBe(true);
}, 5000);
it("accepts valid pollIntervalMs", () => {
const result = parseWatcherConfig({ pollIntervalMs: 2500 });
expect(result.pollIntervalMs).toBe(2500);
}, 5000);
it("rejects negative pollIntervalMs", () => {
try {
parseWatcherConfig({ pollIntervalMs: -1 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(IMessageInvalidConfigError);
const issues = (err as IMessageInvalidConfigError).context[
"issues"
] as readonly { path: readonly unknown[] }[];
expect(issues.length).toBeGreaterThan(0);
expect(issues[0]?.path).toContain("pollIntervalMs");
}
}, 5000);
it("rejects non-Date sinceDate", () => {
expect(() => parseWatcherConfig({ sinceDate: "2024-01-01" })).toThrow(
IMessageInvalidConfigError
);
}, 5000);
it("rejects chatFilter with non-string entries", () => {
expect(() => parseWatcherConfig({ chatFilter: [1, 2] })).toThrow(
IMessageInvalidConfigError
);
}, 5000);
it("rejects unknown keys", () => {
expect(() =>
parseWatcherConfig({ pollIntervalMs: 1000, oops: true })
).toThrow(IMessageInvalidConfigError);
}, 5000);
it("adapter schema accepts valid config", () => {
const result = parseAdapterConfig({
dbPath: "/tmp/chat.db",
includeFromMe: true,
chatFilter: ["+15551234567"],
});
expect(result.dbPath).toBe("/tmp/chat.db");
expect(result.includeFromMe).toBe(true);
expect(result.chatFilter).toEqual(["+15551234567"]);
}, 5000);
it("adapter schema rejects invalid config", () => {
expect(() => parseAdapterConfig({ pollIntervalMs: 0 })).toThrow(
IMessageInvalidConfigError
);
}, 5000);
it("exposes adapter schema alias", () => {
expect(IMessageAdapterConfigSchema).toBe(IMessageWatcherConfigSchema);
}, 5000);
it("SqliteReaderSchema accepts reader-shaped objects", () => {
const ok = SqliteReaderSchema.safeParse({ open: () => null });
expect(ok.success).toBe(true);
const bad = SqliteReaderSchema.safeParse({});
expect(bad.success).toBe(false);
}, 5000);
it("accepts an injected SqliteReader without rejecting", () => {
const reader = { open: () => ({ prepare: () => null }) };
const cfg = parseWatcherConfig({ sqliteReader: reader });
expect(cfg.sqliteReader).toBe(reader);
}, 5000);
it("rejects a non-reader sqliteReader", () => {
expect(() => parseWatcherConfig({ sqliteReader: "nope" })).toThrow(
IMessageInvalidConfigError
);
}, 5000);
});

View File

@@ -0,0 +1,66 @@
import z from "zod";
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
import type {
IMessageAdapterConfig,
IMessageWatcherConfig,
} from "../types/imessage.types.js";
/** Structural Zod schema mirroring `SqliteReader`. */
export const SqliteReaderSchema: z.ZodType<unknown> = z.custom<unknown>(
(value) =>
value !== null &&
typeof value === "object" &&
typeof (value as { open?: unknown }).open === "function",
{ message: "expected a SqliteReader with an open() function" }
);
/** Zod schema for `IMessageWatcherConfig`. */
export const IMessageWatcherConfigSchema = z
.object({
dbPath: z.string().min(1).optional(),
pollIntervalMs: z.number().int().positive().optional(),
sinceDate: z.instanceof(Date).optional(),
includeFromMe: z.boolean().optional(),
chatFilter: z.array(z.string()).optional(),
sqliteReader: SqliteReaderSchema.optional(),
})
.strict();
/** Zod schema for `IMessageAdapterConfig` (same shape as watcher). */
export const IMessageAdapterConfigSchema = IMessageWatcherConfigSchema;
function toIssues(
error: z.ZodError
): ReadonlyArray<Readonly<Record<string, unknown>>> {
return error.issues.map((issue) => ({
code: issue.code,
path: issue.path,
message: issue.message,
}));
}
/** Validate watcher config; throws `IMessageInvalidConfigError` on failure. */
export function parseWatcherConfig(
input: unknown
): Readonly<IMessageWatcherConfig> {
const result = IMessageWatcherConfigSchema.safeParse(input);
if (!result.success) {
throw new IMessageInvalidConfigError({
issues: toIssues(result.error),
});
}
return result.data as IMessageWatcherConfig;
}
/** Validate adapter config; throws `IMessageInvalidConfigError` on failure. */
export function parseAdapterConfig(
input: unknown
): Readonly<IMessageAdapterConfig> {
const result = IMessageAdapterConfigSchema.safeParse(input);
if (!result.success) {
throw new IMessageInvalidConfigError({
issues: toIssues(result.error),
});
}
return result.data as IMessageAdapterConfig;
}

View File

@@ -0,0 +1,76 @@
# `bunSqliteReader`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite`
driver. The reader is the single seam through which
`IMessageWatcher` reaches Apple's `chat.db` SQLite database; replacing
upstream's `require("bun:sqlite") as any` cast with a typed,
swappable factory lets tests inject a reader pointing at a real temp
chat.db without re-implementing the SQL layer.
## Features
- Lazy module resolution via `createRequire(import.meta.url)` — the
`bun:sqlite` import is not added to the static graph, so vite and
vitest do not attempt to resolve it on non-Bun runners during build
/ type-check / lint passes.
- Errors thrown from the load step or the `new Database(...)` call
are wrapped in `IMessageDbOpenFailedError` carrying the offending
path on `context.path`, so the watcher can surface a stable error
code to consumers.
- Returns a `SqliteHandle` whose `execScript` member maps to
`Database.run` (Bun's bulk-SQL entry point) and whose `prepare`
member returns a `SqliteStatement` wrapping Bun's per-statement
`all` / `get` / `run` methods structurally — production and test
code share the exact same call shape.
- Read-only by default: `open(path, { readonly: true })` opens the
database read-only as required for `~/Library/Messages/chat.db`,
while tests open with `readonly: false` to populate the fixture.
## Usage: Basic
```ts
import { bunSqliteReader } from "@suaveplan/imessage-adapter";
const handle = bunSqliteReader.open("/path/to/chat.db", { readonly: true });
const rows = handle.prepare("SELECT ROWID FROM message LIMIT 1").all();
handle.close();
```
## Usage: Advanced
```ts
import { createBunSqliteReader } from "@suaveplan/imessage-adapter";
// Inject a custom loader (e.g. for vitest under WSL2 where bun:sqlite is
// reached through a different worker bridge).
const reader = createBunSqliteReader(() => require("bun:sqlite"));
const handle = reader.open(path, { readonly: false });
handle.execScript("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
```
## API Reference
- `bunSqliteReader: SqliteReader` — default reader, ready for
production wiring.
- `createBunSqliteReader(loader?: BunSqliteLoader): SqliteReader` —
factory accepting a custom loader; defaults to a
`createRequire`-based one that resolves `bun:sqlite` against the
module's own URL.
- `BunSqliteLoader = () => BunSqliteModule` — the loader contract.
## Implementation Notes
`createRequire(import.meta.url)` is the documented Node + Bun bridge
for resolving CommonJS / built-in modules from ESM without statically
declaring them. We use it because static `import "bun:sqlite"` would
make vite resolve the specifier at build time and fail on non-Bun
hosts (CI, lint). The `BunDatabase` interface is intentionally
minimal — it only declares the methods the watcher actually calls,
so changes to Bun's broader Database surface cannot bleed into the
package.

View File

@@ -0,0 +1,66 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
import { bunSqliteReader, createBunSqliteReader } from "./bun-sqlite-reader.js";
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "imsg-sqlite-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("bunSqliteReader", () => {
it("opens a real database in write mode and round-trips a row", () => {
const path = join(dir, "round-trip.db");
const handle = bunSqliteReader.open(path, { readonly: false });
handle.execScript("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
handle.prepare("INSERT INTO t (name) VALUES (?)").run("alpha");
const row = handle.prepare("SELECT name FROM t WHERE id = 1").get();
expect((row as { name: string }).name).toBe("alpha");
const rows = handle.prepare("SELECT id, name FROM t").all();
expect(rows).toHaveLength(1);
handle.close();
}, 5000);
it("wraps loader failures in IMessageDbOpenFailedError", () => {
const failing = createBunSqliteReader(() => {
throw new Error("loader boom");
});
expect(() =>
failing.open(join(dir, "x.db"), { readonly: false })
).toThrow(IMessageDbOpenFailedError);
}, 5000);
it("wraps Database construction failures in IMessageDbOpenFailedError", () => {
// Read-only against a non-existent path throws inside `new Database`.
expect(() =>
bunSqliteReader.open("/non/existent/path/chat.db", {
readonly: true,
})
).toThrow(IMessageDbOpenFailedError);
}, 5000);
it("respects the readonly flag via the create option", () => {
// Create a fresh DB in write mode, close it, then open read-only.
const path = join(dir, "ro.db");
const w = bunSqliteReader.open(path, { readonly: false });
w.execScript("CREATE TABLE foo (x INTEGER)");
w.close();
const r = bunSqliteReader.open(path, { readonly: true });
const row = r.prepare("SELECT count(*) AS n FROM foo").get();
expect((row as { n: number }).n).toBe(0);
r.close();
}, 5000);
});

View File

@@ -0,0 +1,101 @@
import { createRequire } from "node:module";
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
import type {
SqliteHandle,
SqliteReader,
SqliteReaderOpenOptions,
SqliteStatement,
} from "../types/seams.types.js";
/**
* Shape of `bun:sqlite`'s `Database` and `Statement` exposed by Bun's
* built-in driver. Declared structurally so the package type-checks on
* any platform; the actual module is resolved at `open()` time via a
* lazy `createRequire` to keep the dependency out of the static graph.
*/
interface BunDatabaseStatement {
all(...params: readonly unknown[]): readonly unknown[];
get(...params: readonly unknown[]): unknown;
run(...params: readonly unknown[]): unknown;
}
interface BunDatabase {
prepare(sql: string): BunDatabaseStatement;
run(sql: string): void;
close(): void;
}
interface BunSqliteModule {
readonly Database: new (
path: string,
options?: { readonly?: boolean; create?: boolean }
) => BunDatabase;
}
/**
* Factory invoked at `open()` time. Exposed so tests can swap the
* underlying loader; production wiring uses `defaultBunSqliteLoader`.
*/
export type BunSqliteLoader = () => BunSqliteModule;
function defaultBunSqliteLoader(): BunSqliteModule {
const req = createRequire(import.meta.url);
return req("bun:sqlite") as BunSqliteModule;
}
function wrapStatement(stmt: BunDatabaseStatement): SqliteStatement {
return {
all: (...params) => stmt.all(...params),
get: (...params) => stmt.get(...params),
run: (...params) => stmt.run(...params),
};
}
function wrapHandle(db: BunDatabase): SqliteHandle {
return {
prepare: (sql) => wrapStatement(db.prepare(sql)),
execScript: (sql) => {
db.run(sql);
},
close: () => {
db.close();
},
};
}
/**
* Build a `SqliteReader` backed by Bun's built-in `bun:sqlite` driver.
* The `bun:sqlite` module is loaded lazily inside `open()` so the
* package can be type-checked, linted, and unit-tested on any host;
* only the production `open()` call site requires Bun.
*/
export function createBunSqliteReader(
loader: BunSqliteLoader = defaultBunSqliteLoader
): SqliteReader {
return {
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle {
let mod: BunSqliteModule;
try {
mod = loader();
} catch (cause) {
throw new IMessageDbOpenFailedError({
cause,
context: { path },
});
}
try {
const db = new mod.Database(path, {
readonly: options.readonly,
create: !options.readonly,
});
return wrapHandle(db);
} catch (cause) {
throw new IMessageDbOpenFailedError({
cause,
context: { path },
});
}
},
};
}
/** Default production reader resolved against Bun's `bun:sqlite`. */
export const bunSqliteReader: SqliteReader = createBunSqliteReader();

View File

@@ -0,0 +1,5 @@
export type { BunSqliteLoader } from "./bun-sqlite-reader.js";
export {
bunSqliteReader,
createBunSqliteReader,
} from "./bun-sqlite-reader.js";

View File

@@ -0,0 +1,36 @@
/**
* MessageSourceAdapter + InboundMessage interfaces. Mirrors the upstream
* contract so a future @suaveplan/api-plugin-messaging (or any other
* inbound-router consumer) can depend on these names without re-importing
* from the watcher's parser layer.
*/
export type InboundContentType = "text" | "html" | "attachment";
export interface InboundAttachment {
readonly id: string;
readonly filename: string;
readonly mimeType: string;
readonly size: number;
}
export interface InboundMessage {
readonly id: string;
readonly sourceChannel: string;
readonly sourceAddress: string;
readonly content: string;
readonly contentType: InboundContentType;
readonly attachments?: readonly InboundAttachment[];
readonly receivedAt: Date;
readonly rawPayload?: unknown;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface MessageSourceAdapter {
readonly type: string;
initialize(config: Record<string, unknown>): Promise<void>;
poll(): Promise<readonly InboundMessage[]>;
getLastProcessedId(): string | null;
markProcessed(messageId: string): void;
close(): Promise<void>;
}

View File

@@ -0,0 +1,110 @@
/**
* Row + parsed + config types for the iMessage SQLite chat.db schema.
* Preserves the upstream `@suaveplan/imessage-adapter` public surface.
*/
import type { SqliteReader } from "./seams.types.js";
/** Raw row from the `message` table in chat.db. */
export interface IMessageRow {
readonly ROWID: number;
readonly guid: string;
readonly text: string | null;
readonly handle_id: number;
readonly service: string;
readonly date: number;
readonly date_read: number;
readonly date_delivered: number;
readonly is_from_me: number;
readonly is_read: number;
readonly is_delivered: number;
readonly is_sent: number;
readonly cache_has_attachments: number;
readonly associated_message_guid: string | null;
readonly associated_message_type: number;
readonly balloon_bundle_id: string | null;
readonly group_title: string | null;
}
/** Raw row from the `handle` table. */
export interface HandleRow {
readonly ROWID: number;
readonly id: string;
readonly country: string | null;
readonly service: string;
readonly uncanonicalized_id: string | null;
}
/** Raw row from the `chat` table. */
export interface ChatRow {
readonly ROWID: number;
readonly guid: string;
readonly chat_identifier: string;
readonly display_name: string | null;
readonly group_id: string | null;
readonly service_name: string;
readonly is_archived: number;
}
/** Raw row from the `attachment` table. */
export interface AttachmentRow {
readonly ROWID: number;
readonly guid: string;
readonly filename: string | null;
readonly mime_type: string | null;
readonly total_bytes: number;
readonly transfer_name: string | null;
}
/** Parsed iMessage with resolved handles and chat info. */
export interface ParsedIMessage {
readonly id: string;
readonly guid: string;
readonly text: string;
readonly senderHandle: string;
readonly senderService: string;
readonly chatIdentifier: string;
readonly chatDisplayName: string | null;
readonly isFromMe: boolean;
readonly isRead: boolean;
readonly isGroupChat: boolean;
readonly sentAt: Date;
readonly readAt: Date | null;
readonly attachments: readonly ParsedAttachment[];
}
/** Parsed attachment info. */
export interface ParsedAttachment {
readonly id: string;
readonly filename: string;
readonly mimeType: string;
readonly size: number;
}
/** Watcher configuration. */
export interface IMessageWatcherConfig {
readonly dbPath?: string;
readonly pollIntervalMs?: number;
readonly sinceDate?: Date;
readonly includeFromMe?: boolean;
readonly chatFilter?: readonly string[];
readonly sqliteReader?: SqliteReader;
}
/** Adapter configuration (superset of watcher config; identical shape). */
export interface IMessageAdapterConfig {
readonly dbPath?: string;
readonly pollIntervalMs?: number;
readonly sinceDate?: Date;
readonly includeFromMe?: boolean;
readonly chatFilter?: readonly string[];
readonly sqliteReader?: SqliteReader;
}
/** Watcher event topic map (legacy on/off surface + EventBus surface). */
export type IMessageWatcherEvents = {
readonly message: ParsedIMessage;
readonly error: Error;
readonly started: undefined;
readonly stopped: undefined;
};

View File

@@ -0,0 +1,26 @@
/**
* Structural SQLite seams. Replaces upstream's `require("bun:sqlite") as any`
* cast with a typed interface that production (`bunSqliteReader`) and tests
* (the chat-db fixture, which itself uses `bun:sqlite`) both implement.
*/
export interface SqliteStatement {
all(...params: readonly unknown[]): readonly unknown[];
get(...params: readonly unknown[]): unknown;
run(...params: readonly unknown[]): unknown;
}
export interface SqliteHandle {
prepare(sql: string): SqliteStatement;
/** Executes raw DDL/DML; SQLite's bulk exec, not a process call. */
execScript(sql: string): void;
close(): void;
}
export interface SqliteReaderOpenOptions {
readonly readonly: boolean;
}
export interface SqliteReader {
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle;
}

View File

@@ -0,0 +1,103 @@
# `IMessageWatcher`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Polls Apple's `~/Library/Messages/chat.db` SQLite database on a
configurable interval, projects new rows into `ParsedIMessage`, and
emits them on both a legacy `on/off` listener API and the typed
`EventBus<IMessageWatcherEvents>` exposed as the public `bus` field.
The watcher is the canonical ingress for any surface that
wants to consume inbound macOS Messages traffic — adapter, router,
or direct subscriber.
## Features
- Zod-validated config boundary (`pollIntervalMs > 0`,
`sinceDate: Date`, etc.) via `parseWatcherConfig` — invalid input
throws `IMessageInvalidConfigError` at construction time.
- Injectable `SqliteReader` (defaults to the production
`bunSqliteReader`) so tests use a real `bun:sqlite` chat.db fixture
without mocks.
- `start({ signal })` schedules polls every `pollIntervalMs`; the
signal stops the loop and emits a `stopped` event.
- `poll({ signal })` runs one tick on demand; pre-aborted signals
throw `IMessagePollAbortedError`. Errors from explicit polls bubble
as `IMessageDbQueryFailedError`; errors from the internal scheduler
emit `error` on the bus and continue ticking (mirrors upstream
behaviour so a transient SQLite error doesn't tear down the loop).
- Monotonic `lastRowId` cursor — only emits rows with `ROWID >
lastRowId`; advances even for filtered rows so the cursor doesn't
re-process them next tick. `getLastRowId()` / `setLastRowId(n)`
expose the cursor for consumers that want to persist resume state.
- Filters: `includeFromMe: false` drops outgoing messages; non-empty
`chatFilter` restricts to specific `chat_identifier`s.
- Four OTel instruments — `imessage.messages_emitted_total`,
`imessage.attachments_emitted_total`,
`imessage.poll_errors_total`, `imessage.poll_duration_ms` — wired
into every emit / error / tick.
## Usage: Basic
```ts
import { IMessageWatcher } from "@suaveplan/imessage-adapter";
const watcher = new IMessageWatcher({ pollIntervalMs: 5000 });
const off = watcher.on("message", (msg) => console.log(msg.text));
await watcher.start();
// ...
off();
await watcher.stop();
```
## Usage: Advanced
```ts
import {
IMessageWatcher,
createChatDbFixture,
createBunSqliteReader,
} from "@suaveplan/imessage-adapter";
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, "/tmp/chat.db");
// ... populate fixture
const watcher = new IMessageWatcher({
dbPath: "/tmp/chat.db",
sqliteReader: reader,
includeFromMe: true,
chatFilter: ["+15551234567"],
});
const controller = new AbortController();
const parsed = await watcher.poll({ signal: controller.signal });
```
## API Reference
- `new IMessageWatcher(config?: IMessageWatcherConfig)` — Zod-validates
at construction.
- `bus: EventBus<IMessageWatcherEvents>` — typed event surface (`message`,
`error`, `started`, `stopped`).
- `on(event, listener) => () => void` — legacy alias for `bus.on`.
- `off(event, listener): void` — legacy alias for `bus.off`.
- `start(opts?: { signal?: AbortSignal }): Promise<void>` — throws
`IMessageWatcherAlreadyRunningError` on double-start.
- `stop(): Promise<void>` — throws `IMessageWatcherNotRunningError`
when called while not running.
- `poll(opts?: { signal?: AbortSignal }): Promise<readonly ParsedIMessage[]>`.
- `getLastRowId(): number` / `setLastRowId(rowId: number): void`.
- `isRunning(): boolean`.
## Implementation Notes
The legacy `on/off` API delegates straight to the typed `EventBus`,
which keeps the two surfaces in lockstep — there is no separate
listener registry. The cursor advances inside the row loop *before*
filters apply, so filtered rows are not re-fetched next tick. The
scheduler-vs-explicit-poll error policy is asymmetric on purpose: a
caller invoking `poll({ signal })` directly wants the error; the
internal scheduler keeps ticking so a transient DB lock doesn't tear
down the consumer.

View File

@@ -0,0 +1,639 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
IMessageDbQueryFailedError,
IMessageError,
IMessageInvalidConfigError,
IMessagePollAbortedError,
IMessageWatcherAlreadyRunningError,
IMessageWatcherNotRunningError,
} from "../error-codes/error-codes.js";
import {
type ChatDbFixture,
createChatDbFixture,
} from "../fixtures/chat-db-fixture.js";
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
import type {
ChatRow,
HandleRow,
IMessageRow,
ParsedIMessage,
} from "../types/imessage.types.js";
import { IMessageWatcher } from "./imessage-watcher.js";
const reader = createBunSqliteReader();
let dir: string;
let dbPath: string;
let fixture: ChatDbFixture;
function defaultMessage(
overrides: Partial<Omit<IMessageRow, "ROWID">> = {}
): Omit<IMessageRow, "ROWID"> {
return {
guid: "g",
text: "hello",
handle_id: 1,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 0,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
...overrides,
};
}
function defaultHandle(
overrides: Partial<Omit<HandleRow, "ROWID">> = {}
): Omit<HandleRow, "ROWID"> {
return {
id: "+15551234567",
country: "us",
service: "iMessage",
uncanonicalized_id: null,
...overrides,
};
}
function defaultChat(
overrides: Partial<Omit<ChatRow, "ROWID">> = {}
): Omit<ChatRow, "ROWID"> {
return {
guid: "chat-g",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
...overrides,
};
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "imsg-watch-"));
dbPath = join(dir, "chat.db");
fixture = createChatDbFixture(reader, dbPath);
});
afterEach(async () => {
fixture.close();
rmSync(dir, { recursive: true, force: true });
});
describe("IMessageWatcher", () => {
it("constructor rejects invalid config via Zod", () => {
expect(() => new IMessageWatcher({ pollIntervalMs: -1 })).toThrow(
IMessageInvalidConfigError
);
}, 5000);
it("watcher tests run against a real bun:sqlite fixture", async () => {
const handleId = fixture.insertHandle(defaultHandle());
const chatId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const messages = await watcher.poll();
expect(messages).toHaveLength(1);
expect(messages[0]?.text).toBe("hello");
}, 10_000);
it("poll yields fixture-inserted messages via injected SqliteReader", async () => {
const handleId = fixture.insertHandle(defaultHandle());
const chatId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
fixture.insertMessage(
defaultMessage({ handle_id: handleId, guid: "g2", text: "two" }),
chatId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const msgs = await watcher.poll();
expect(msgs.map((m) => m.text)).toEqual(["hello", "two"]);
}, 10_000);
it("poll advances lastRowId", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
fixture.insertMessage(
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
cId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await watcher.poll();
expect(watcher.getLastRowId()).toBe(2);
// Subsequent poll with no new rows yields nothing.
const second = await watcher.poll();
expect(second).toHaveLength(0);
// Insert a new row; lastRowId should advance.
fixture.insertMessage(
defaultMessage({ handle_id: hId, guid: "g3", text: "three" }),
cId
);
const third = await watcher.poll();
expect(third).toHaveLength(1);
expect(watcher.getLastRowId()).toBe(3);
}, 10_000);
it("includeFromMe=false drops outgoing", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
cId
);
fixture.insertMessage(
defaultMessage({ handle_id: hId, text: "in" }),
cId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
includeFromMe: false,
});
const msgs = await watcher.poll();
expect(msgs).toHaveLength(1);
expect(msgs[0]?.text).toBe("in");
// lastRowId still advanced past the filtered row.
expect(watcher.getLastRowId()).toBe(2);
}, 10_000);
it("includeFromMe=true keeps outgoing", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
cId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
includeFromMe: true,
});
const msgs = await watcher.poll();
expect(msgs).toHaveLength(1);
}, 10_000);
it("chatFilter restricts emitted messages", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cA = fixture.insertChat(defaultChat({ chat_identifier: "A" }));
const cB = fixture.insertChat(
defaultChat({ guid: "g-B", chat_identifier: "B" })
);
fixture.insertMessage(defaultMessage({ handle_id: hId }), cA);
fixture.insertMessage(
defaultMessage({ handle_id: hId, guid: "m2" }),
cB
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
chatFilter: ["A"],
});
const msgs = await watcher.poll();
expect(msgs.map((m) => m.chatIdentifier)).toEqual(["A"]);
}, 10_000);
it("attachments are joined and emitted", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
const mId = fixture.insertMessage(
defaultMessage({
handle_id: hId,
cache_has_attachments: 1,
}),
cId
);
fixture.insertAttachment(
{
guid: "a",
filename: "/tmp/p.jpg",
mime_type: "image/jpeg",
total_bytes: 100,
transfer_name: "p.jpg",
},
mId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const msgs = await watcher.poll();
expect(msgs[0]?.attachments).toHaveLength(1);
expect(msgs[0]?.attachments[0]?.mimeType).toBe("image/jpeg");
}, 10_000);
it("attachment with unusual mime emits 'other' mime_family", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
const mId = fixture.insertMessage(
defaultMessage({ handle_id: hId, cache_has_attachments: 1 }),
cId
);
// mime_type defaults to application/octet-stream in parser when null.
fixture.insertAttachment(
{
guid: "b",
filename: null,
mime_type: null,
total_bytes: 1,
transfer_name: null,
},
mId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const msgs = await watcher.poll();
expect(msgs[0]?.attachments[0]?.mimeType).toBe(
"application/octet-stream"
);
}, 10_000);
it("message with handle_id=0 resolves to null handle", async () => {
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(
defaultMessage({ handle_id: 0, text: "system" }),
cId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const msgs = await watcher.poll();
expect(msgs[0]?.senderHandle).toBe("unknown");
}, 10_000);
it("poll rejects on pre-aborted signal", async () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await expect(
watcher.poll({ signal: AbortSignal.abort() })
).rejects.toBeInstanceOf(IMessagePollAbortedError);
}, 5000);
it("poll aborts mid-iteration when signal fires", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
for (let i = 0; i < 5; i++) {
fixture.insertMessage(
defaultMessage({
handle_id: hId,
guid: `g${i}`,
text: `m${i}`,
}),
cId
);
}
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const controller = new AbortController();
// Abort immediately so the loop fires on the first iteration's
// check.
controller.abort();
await expect(
watcher.poll({ signal: controller.signal })
).rejects.toBeInstanceOf(IMessagePollAbortedError);
}, 10_000);
it("start/stop lifecycle with abort signal terminates the loop", async () => {
const controller = new AbortController();
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const events: string[] = [];
watcher.on("started", () => events.push("started"));
watcher.on("stopped", () => events.push("stopped"));
await watcher.start({ signal: controller.signal });
expect(watcher.isRunning()).toBe(true);
controller.abort();
// Aborting calls stop() synchronously via the abort handler.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(watcher.isRunning()).toBe(false);
expect(events).toEqual(["started", "stopped"]);
}, 10_000);
it("start rejects when called twice", async () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await watcher.start();
await expect(watcher.start()).rejects.toBeInstanceOf(
IMessageWatcherAlreadyRunningError
);
await watcher.stop();
}, 5000);
it("start with pre-aborted signal throws POLL_ABORTED", async () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await expect(
watcher.start({ signal: AbortSignal.abort() })
).rejects.toBeInstanceOf(IMessagePollAbortedError);
}, 5000);
it("stop rejects when not running", async () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await expect(watcher.stop()).rejects.toBeInstanceOf(
IMessageWatcherNotRunningError
);
}, 5000);
it("scheduler emits error event when underlying poll fails", async () => {
const failingReader = {
open: () => {
throw new Error("nope");
},
};
const watcher = new IMessageWatcher({
dbPath: "/x",
pollIntervalMs: 5,
sqliteReader: failingReader,
});
const errors: Error[] = [];
watcher.on("error", (e) => errors.push(e));
await watcher.start();
await new Promise((resolve) => setTimeout(resolve, 50));
await watcher.stop();
expect(errors.length).toBeGreaterThan(0);
}, 10_000);
it("scheduler with signal catches poll rejection", async () => {
const failingReader = {
open: () => {
throw new Error("scheduled-with-signal");
},
};
const watcher = new IMessageWatcher({
dbPath: "/x",
pollIntervalMs: 5,
sqliteReader: failingReader,
});
const errors: Error[] = [];
watcher.on("error", (e) => errors.push(e));
const controller = new AbortController();
await watcher.start({ signal: controller.signal });
await new Promise((resolve) => setTimeout(resolve, 30));
controller.abort();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(errors.length).toBeGreaterThan(0);
}, 10_000);
it("scheduler catch path tolerates non-Error rejection values", async () => {
const reader = {
open: () => {
const literal: unknown = "string-rejection";
throw literal;
},
};
const watcher = new IMessageWatcher({
dbPath: "/x",
pollIntervalMs: 5,
sqliteReader: reader,
});
const errors: Error[] = [];
watcher.on("error", (e) => errors.push(e));
const controller = new AbortController();
await watcher.start({ signal: controller.signal });
await new Promise((resolve) => setTimeout(resolve, 30));
controller.abort();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(errors.length).toBeGreaterThan(0);
}, 10_000);
it("explicit poll wraps reader errors in IMessageDbQueryFailedError", async () => {
const failingReader = {
open: () => {
throw new Error("locked");
},
};
const watcher = new IMessageWatcher({
dbPath: "/x",
pollIntervalMs: 60_000,
sqliteReader: failingReader,
});
const ctrl = new AbortController();
await expect(
watcher.poll({ signal: ctrl.signal })
).rejects.toBeInstanceOf(IMessageError);
}, 5000);
it("explicit poll without a signal returns [] on reader error", async () => {
const failingReader = {
open: () => {
throw new Error("transient");
},
};
const watcher = new IMessageWatcher({
dbPath: "/x",
pollIntervalMs: 60_000,
sqliteReader: failingReader,
});
const errors: Error[] = [];
watcher.on("error", (e) => errors.push(e));
const result = await watcher.poll();
expect(result).toEqual([]);
expect(errors).toHaveLength(1);
}, 5000);
it("legacy on() delegates to the bus", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const seen: ParsedIMessage[] = [];
const off = watcher.on("message", (m) => seen.push(m));
await watcher.poll();
expect(seen).toHaveLength(1);
off();
// After unregistering, the next poll for a new row should not
// deliver.
fixture.insertMessage(
defaultMessage({ handle_id: hId, guid: "g2", text: "t2" }),
cId
);
await watcher.poll();
expect(seen).toHaveLength(1);
}, 10_000);
it("off() removes a listener registered via on()", async () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const seen: ParsedIMessage[] = [];
const handler = (m: ParsedIMessage): void => {
seen.push(m);
};
watcher.on("message", handler);
watcher.off("message", handler);
// Emit directly via the bus.
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
await watcher.poll();
expect(seen).toHaveLength(0);
}, 5000);
it("setLastRowId rejects invalid values", () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
expect(() => watcher.setLastRowId(-1)).toThrow(
IMessageDbQueryFailedError
);
expect(() => watcher.setLastRowId(Number.NaN)).toThrow(
IMessageDbQueryFailedError
);
}, 5000);
it("setLastRowId accepts a positive value", () => {
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
watcher.setLastRowId(42);
expect(watcher.getLastRowId()).toBe(42);
}, 5000);
it("poll emits messages_emitted_total + poll_duration_ms via OTel", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
fixture.insertMessage(
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
cId
);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const messages = await watcher.poll();
expect(messages).toHaveLength(2);
// OTel emission is asserted in metrics.test.ts; here we only
// confirm the path doesn't throw and yields the expected count.
}, 10_000);
it("chat with null display_name and group_title falls back to null", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
const msgs = await watcher.poll();
expect(msgs[0]?.chatDisplayName).toBeNull();
}, 10_000);
it("uses default config when no input provided", () => {
// We cannot reach a real chat.db here, but the constructor with
// the production reader must not throw on an empty config.
const watcher = new IMessageWatcher();
expect(watcher.isRunning()).toBe(false);
expect(watcher.getLastRowId()).toBe(0);
}, 5000);
it("message ROWID > lastRowId filter omits older rows", async () => {
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
watcher.setLastRowId(5);
const msgs = await watcher.poll();
expect(msgs).toHaveLength(0);
}, 10_000);
it("explicit poll without signal aborted mid-iteration: covers the inner check", async () => {
// Insert many rows; abort the controller AFTER opening the
// database but BEFORE iteration completes by passing a controller
// that aborts immediately.
const hId = fixture.insertHandle(defaultHandle());
const cId = fixture.insertChat(defaultChat());
for (let i = 0; i < 3; i++) {
fixture.insertMessage(
defaultMessage({
handle_id: hId,
guid: `gx${i}`,
text: `tx${i}`,
}),
cId
);
}
const ctrl = new AbortController();
ctrl.abort();
const watcher = new IMessageWatcher({
dbPath,
pollIntervalMs: 60_000,
sqliteReader: reader,
});
await expect(
watcher.poll({ signal: ctrl.signal })
).rejects.toBeInstanceOf(IMessagePollAbortedError);
}, 10_000);
});

View File

@@ -0,0 +1,271 @@
import { EventBus } from "@suaveplan/events";
import {
IMessageDbQueryFailedError,
IMessagePollAbortedError,
IMessageWatcherAlreadyRunningError,
IMessageWatcherNotRunningError,
} from "../error-codes/error-codes.js";
import {
recordAttachmentEmitted,
recordMessageEmitted,
recordPollDuration,
recordPollError,
} from "../otel/metrics.js";
import { IMessageParser } from "../parser/imessage-parser.js";
import { parseWatcherConfig } from "../schemas/schemas.js";
import { bunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
import type {
AttachmentRow,
ChatRow,
HandleRow,
IMessageRow,
IMessageWatcherConfig,
IMessageWatcherEvents,
ParsedIMessage,
} from "../types/imessage.types.js";
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
const DEFAULT_DB_PATH = "~/Library/Messages/chat.db";
const DEFAULT_POLL_INTERVAL = 5000;
interface ResolvedConfig {
readonly dbPath: string;
readonly pollIntervalMs: number;
readonly sinceDate: Date;
readonly includeFromMe: boolean;
readonly chatFilter: readonly string[];
readonly sqliteReader: SqliteReader;
}
export interface IMessageWatcherStartOptions {
readonly signal?: AbortSignal;
}
export interface IMessagePollOptions {
readonly signal?: AbortSignal;
}
/**
* Polls Apple's iMessage chat.db SQLite database on a configurable
* interval, projects new rows into `ParsedIMessage`, and emits them on
* both a legacy `on/off` listener registry and the typed `EventBus`
* exposed as `watcher.bus`.
*/
export class IMessageWatcher {
public readonly bus: EventBus<IMessageWatcherEvents>;
private readonly config: ResolvedConfig;
private readonly parser: IMessageParser;
private lastRowId = 0;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private running = false;
private abortListener: (() => void) | null = null;
private currentSignal: AbortSignal | null = null;
public constructor(input: IMessageWatcherConfig = {}) {
const parsed = parseWatcherConfig(input);
this.config = {
dbPath: parsed.dbPath ?? DEFAULT_DB_PATH,
pollIntervalMs: parsed.pollIntervalMs ?? DEFAULT_POLL_INTERVAL,
sinceDate: parsed.sinceDate ?? new Date(),
includeFromMe: parsed.includeFromMe ?? false,
chatFilter: parsed.chatFilter ?? [],
sqliteReader: parsed.sqliteReader ?? bunSqliteReader,
};
this.parser = new IMessageParser();
this.bus = new EventBus<IMessageWatcherEvents>();
}
/** Register a listener (legacy API; delegates to the typed bus). */
public on<K extends keyof IMessageWatcherEvents>(
event: K,
listener: (payload: IMessageWatcherEvents[K]) => void
): () => void {
return this.bus.on(event, listener);
}
/** Remove a listener (legacy API; delegates to the typed bus). */
public off<K extends keyof IMessageWatcherEvents>(
event: K,
listener: (payload: IMessageWatcherEvents[K]) => void
): void {
this.bus.off(event, listener);
}
public async start(
options: IMessageWatcherStartOptions = {}
): Promise<void> {
if (this.running) throw new IMessageWatcherAlreadyRunningError();
const signal = options.signal;
if (signal?.aborted) {
throw new IMessagePollAbortedError();
}
this.running = true;
this.currentSignal = signal ?? null;
if (signal) {
const handler = (): void => {
void this.stop();
};
this.abortListener = handler;
signal.addEventListener("abort", handler, { once: true });
}
this.bus.emit("started", undefined);
this.pollTimer = setInterval(() => {
this.poll(signal ? { signal } : {}).catch((err: unknown) => {
const error =
err instanceof Error ? err : new Error(String(err));
this.bus.emit("error", error);
});
}, this.config.pollIntervalMs);
}
public async stop(): Promise<void> {
if (!this.running) throw new IMessageWatcherNotRunningError();
this.running = false;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.currentSignal && this.abortListener) {
this.currentSignal.removeEventListener("abort", this.abortListener);
}
this.abortListener = null;
this.currentSignal = null;
this.bus.emit("stopped", undefined);
}
public async poll(
options: IMessagePollOptions = {}
): Promise<readonly ParsedIMessage[]> {
const signal = options.signal;
if (signal?.aborted) {
recordPollDuration(0, "aborted");
throw new IMessagePollAbortedError();
}
const started = Date.now();
let handle: SqliteHandle | null = null;
try {
handle = this.config.sqliteReader.open(this.config.dbPath, {
readonly: true,
});
const rows = this.queryNewMessages(handle);
const parsed: ParsedIMessage[] = [];
for (const row of rows) {
if (signal?.aborted) {
recordPollDuration(Date.now() - started, "aborted");
throw new IMessagePollAbortedError();
}
const handleRow = this.queryHandle(handle, row.handle_id);
const chatRow = this.queryChat(handle, row.ROWID);
const attachments: readonly AttachmentRow[] =
row.cache_has_attachments
? this.queryAttachments(handle, row.ROWID)
: [];
const message = this.parser.parseMessage(
row,
handleRow,
chatRow,
attachments
);
if (row.ROWID > this.lastRowId) {
this.lastRowId = row.ROWID;
}
if (!this.config.includeFromMe && message.isFromMe) continue;
if (
this.config.chatFilter.length > 0 &&
!this.config.chatFilter.includes(message.chatIdentifier)
) {
continue;
}
parsed.push(message);
this.bus.emit("message", message);
recordMessageEmitted(
message.isGroupChat ? "group" : "direct",
message.isFromMe
);
for (const attachment of message.attachments) {
const family = attachment.mimeType.split("/")[0] ?? "other";
recordAttachmentEmitted(family);
}
}
recordPollDuration(Date.now() - started, "ok");
return parsed;
} catch (cause) {
recordPollDuration(Date.now() - started, "error");
if (cause instanceof IMessagePollAbortedError) {
throw cause;
}
const queryError = new IMessageDbQueryFailedError({ cause });
recordPollError(queryError.code);
const err =
cause instanceof Error ? cause : new Error(String(cause));
this.bus.emit("error", err);
// Wrap-and-rethrow only when a caller explicitly polled (no
// signal-driven internal scheduler); otherwise swallow to
// mirror upstream behaviour where the loop keeps ticking.
if (signal !== undefined) throw queryError;
return [];
} finally {
if (handle) handle.close();
}
}
public getLastRowId(): number {
return this.lastRowId;
}
public setLastRowId(rowId: number): void {
if (!Number.isFinite(rowId) || rowId < 0) {
throw new IMessageDbQueryFailedError({
message: "invalid lastRowId",
context: { rowId },
});
}
this.lastRowId = rowId;
}
public isRunning(): boolean {
return this.running;
}
private queryNewMessages(handle: SqliteHandle): readonly IMessageRow[] {
const stmt = handle.prepare(
"SELECT * FROM message WHERE ROWID > ? ORDER BY ROWID ASC LIMIT 100"
);
return stmt.all(this.lastRowId) as readonly IMessageRow[];
}
private queryHandle(
handle: SqliteHandle,
handleId: number
): HandleRow | null {
if (!handleId) return null;
const stmt = handle.prepare("SELECT * FROM handle WHERE ROWID = ?");
const row = stmt.get(handleId);
return (row as HandleRow | undefined) ?? null;
}
private queryChat(
handle: SqliteHandle,
messageRowId: number
): ChatRow | null {
const stmt = handle.prepare(
`SELECT c.* FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
WHERE cmj.message_id = ?`
);
const row = stmt.get(messageRowId);
return (row as ChatRow | undefined) ?? null;
}
private queryAttachments(
handle: SqliteHandle,
messageRowId: number
): readonly AttachmentRow[] {
const stmt = handle.prepare(
`SELECT a.* FROM attachment a
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?`
);
return stmt.all(messageRowId) as readonly AttachmentRow[];
}
}

View File

@@ -0,0 +1,5 @@
export type {
IMessagePollOptions,
IMessageWatcherStartOptions,
} from "./imessage-watcher.js";
export { IMessageWatcher } from "./imessage-watcher.js";

View File

@@ -0,0 +1,38 @@
/**
* Test-environment setup for @suaveplan/imessage-adapter.
*
* 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 noop fallback). Tests inspect emitted
* instruments by calling `flushTestMetrics()`.
*
* Server-tier (bun:sqlite required): no DOM, no browser 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,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/imessage-adapter",
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",
"bun:sqlite",
/^node:/,
],
},
},
});

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");
});
});

Some files were not shown because too many files have changed in this diff Show More