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,85 @@
# `delivery-tracker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`delivery-tracker` is the bounded LRU cache of
`SmsDeliveryReport` records that `SmsService` consults for
delivery status. Receipts may arrive via webhook ingest
(`tracker.ingest(report)`) or be lazily fetched from the
configured provider via `tracker.lookupOrFetch(messageId,
provider)`. The cache is bounded so a long-running service does
not retain every receipt indefinitely.
When no provider is supplied and the cache misses,
`lookupOrFetch` throws `SmsReceiptNotFoundError` (code
`SMS.RECEIPT_NOT_FOUND`) so callers can distinguish a miss from
a remote provider error.
## Features
- LRU cache keyed by `messageId`, bounded by `maxEntries`
(default 1024).
- `ingest(report)` — record / refresh a receipt; oldest entry
evicts on overflow.
- `getStatus(messageId)` — synchronous cache read; refreshes
LRU order on hit.
- `lookupOrFetch(messageId, provider?)` — cache-first, falls
back to `provider.getStatus`; result is cached on success.
- `size()` and `clear()` for observation / reset.
## Usage: Basic
```ts
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
const tracker = new DeliveryTracker();
tracker.ingest({
messageId: "m1",
to: "+15551234567",
status: "delivered",
timestamp: new Date(),
});
console.log(tracker.getStatus("m1"));
```
## Usage: Advanced
```ts
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
const tracker = new DeliveryTracker({ maxEntries: 4 });
const provider = new InMemorySmsProvider();
// Lazy fetch — first call hits the provider, second hits cache.
const fromProvider = await tracker.lookupOrFetch("m1", provider);
const fromCache = await tracker.lookupOrFetch("m1", provider);
```
## API Reference
- `class DeliveryTracker`
- `constructor(options?: DeliveryTrackerOptions)`
- `ingest(report: SmsDeliveryReport): void`
- `getStatus(messageId: string): SmsDeliveryReport | undefined`
- `lookupOrFetch(messageId: string, provider?: SmsProvider):
Promise<SmsDeliveryReport>`
- `size(): number`
- `clear(): void`
- `interface DeliveryTrackerOptions`
- `maxEntries?: number` (default 1024, must be ≥ 1)
## Implementation Notes
The implementation uses a plain `Map` and exploits JavaScript's
insertion-ordered iteration to maintain LRU order: on each hit
we `delete` then `set` the entry, pushing it to the back. On
`ingest`, an existing entry is deleted before re-insertion to
keep order canonical. Eviction reads the first key in the
iterator (the oldest entry) and deletes it. `lookupOrFetch`
catches no provider errors — they propagate to the caller so a
network failure on receipt fetch isn't silently swallowed.

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SmsReceiptNotFoundError } from "../error-codes/error-codes.js";
import type {
SmsDeliveryReport,
SmsMessage,
SmsProvider,
} from "../types/sms.types.js";
import { DeliveryTracker } from "./delivery-tracker.js";
function makeReport(messageId: string): SmsDeliveryReport {
return {
messageId,
to: "+15551234567",
status: "delivered",
timestamp: new Date(),
};
}
class FixedProvider implements SmsProvider {
public readonly name = "fixed";
public callCount = 0;
public constructor(private readonly report: SmsDeliveryReport) {}
public async send(msg: SmsMessage): Promise<SmsDeliveryReport> {
return { ...this.report, to: msg.to };
}
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
this.callCount += 1;
return { ...this.report, messageId };
}
}
describe("DeliveryTracker", () => {
it("ingest/getStatus round-trip", { timeout: 5000 }, () => {
const tracker = new DeliveryTracker();
const report = makeReport("m1");
tracker.ingest(report);
expect(tracker.getStatus("m1")).toEqual(report);
});
it("LRU eviction", { timeout: 5000 }, () => {
const tracker = new DeliveryTracker({ maxEntries: 2 });
tracker.ingest(makeReport("m1"));
tracker.ingest(makeReport("m2"));
tracker.ingest(makeReport("m3"));
expect(tracker.getStatus("m1")).toBeUndefined();
expect(tracker.getStatus("m2")).toBeDefined();
expect(tracker.getStatus("m3")).toBeDefined();
expect(tracker.size()).toBe(2);
});
it("getStatus refreshes LRU order", { timeout: 5000 }, () => {
const tracker = new DeliveryTracker({ maxEntries: 2 });
tracker.ingest(makeReport("m1"));
tracker.ingest(makeReport("m2"));
// Touch m1 to refresh order
tracker.getStatus("m1");
tracker.ingest(makeReport("m3"));
expect(tracker.getStatus("m2")).toBeUndefined();
expect(tracker.getStatus("m1")).toBeDefined();
});
it("re-ingest of same id refreshes order without growing", {
timeout: 5000,
}, () => {
const tracker = new DeliveryTracker({ maxEntries: 2 });
tracker.ingest(makeReport("m1"));
tracker.ingest(makeReport("m1"));
expect(tracker.size()).toBe(1);
});
it("lookupOrFetch returns cached entry", { timeout: 5000 }, async () => {
const tracker = new DeliveryTracker();
const report = makeReport("m1");
tracker.ingest(report);
const out = await tracker.lookupOrFetch("m1");
expect(out).toEqual(report);
});
it("lookupOrFetch falls back to provider", {
timeout: 5000,
}, async () => {
const tracker = new DeliveryTracker();
const provider = new FixedProvider(makeReport("m1"));
const out = await tracker.lookupOrFetch("m1", provider);
expect(out.messageId).toBe("m1");
expect(provider.callCount).toBe(1);
// Second call hits cache
const cached = await tracker.lookupOrFetch("m1", provider);
expect(cached.messageId).toBe("m1");
expect(provider.callCount).toBe(1);
});
it("miss without provider throws receipt-not-found", {
timeout: 5000,
}, async () => {
const tracker = new DeliveryTracker();
await expect(tracker.lookupOrFetch("absent")).rejects.toBeInstanceOf(
SmsReceiptNotFoundError
);
});
it("clear empties the cache", { timeout: 5000 }, () => {
const tracker = new DeliveryTracker();
tracker.ingest(makeReport("m1"));
tracker.clear();
expect(tracker.size()).toBe(0);
});
it("rejects maxEntries < 1", { timeout: 5000 }, () => {
expect(() => new DeliveryTracker({ maxEntries: 0 })).toThrow();
});
});

View File

@@ -0,0 +1,71 @@
import { SmsReceiptNotFoundError } from "../error-codes/error-codes.js";
import type {
DeliveryTrackerOptions,
SmsDeliveryReport,
SmsProvider,
} from "../types/sms.types.js";
/**
* Bounded LRU cache of `SmsDeliveryReport` keyed by `messageId`. Receipts
* may arrive via webhook ingest (`ingest`) or be lazily fetched from a
* provider (`lookupOrFetch`). The cache is bounded so a long-running
* service does not retain every receipt indefinitely.
*/
export class DeliveryTracker {
private readonly maxEntries: number;
private readonly cache = new Map<string, SmsDeliveryReport>();
public constructor(options: DeliveryTrackerOptions = {}) {
this.maxEntries = options.maxEntries ?? 1024;
if (this.maxEntries < 1) {
throw new Error("DeliveryTracker.maxEntries must be ≥ 1");
}
}
/** Record a delivery report; evicts the oldest entry past `maxEntries`. */
public ingest(report: SmsDeliveryReport): void {
if (this.cache.has(report.messageId)) {
this.cache.delete(report.messageId);
}
this.cache.set(report.messageId, report);
if (this.cache.size > this.maxEntries) {
const oldest = this.cache.keys().next().value as string;
this.cache.delete(oldest);
}
}
/** Read the cached status; returns `undefined` on miss. */
public getStatus(messageId: string): SmsDeliveryReport | undefined {
const entry = this.cache.get(messageId);
if (entry === undefined) return undefined;
// Re-insert to refresh LRU order.
this.cache.delete(messageId);
this.cache.set(messageId, entry);
return entry;
}
/** Cache miss → consult provider; throws `SmsReceiptNotFoundError` if no provider. */
public async lookupOrFetch(
messageId: string,
provider?: SmsProvider
): Promise<SmsDeliveryReport> {
const cached = this.getStatus(messageId);
if (cached !== undefined) return cached;
if (provider === undefined) {
throw new SmsReceiptNotFoundError({ context: { messageId } });
}
const report = await provider.getStatus(messageId);
this.ingest(report);
return report;
}
/** Number of cached entries. */
public size(): number {
return this.cache.size;
}
/** Drop every cached entry. */
public clear(): void {
this.cache.clear();
}
}

View File

@@ -0,0 +1 @@
export { DeliveryTracker } from "./delivery-tracker.js";

View File

@@ -0,0 +1,110 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the canonical `SMS.*` registry for
`@suaveplan/sms`. Every error surfaced by the package — invalid
phone number, missing provider, exhausted retries, opted-out
recipient, rate limit, scheduled-message cancellation, abort —
carries one of the thirteen stable codes registered here. This
keeps downstream consumers (loggers, problem+json responders,
retry wrappers, dashboards) free from re-implementing the
classifier.
The thirteen codes are declared with the
`defineCodes("SMS", [...])` helper, which produces a frozen
`Namespace.Reason` string union. Each code has a dedicated
leaf-error class extending the package-local abstract
`BaseSmsError` (itself a thin `SuaveplanError<SmsCode>`
subclass), so consumers can `instanceof` the class as well as
match on `.code`.
## Features
- Thirteen `SMS.*` codes: `INVALID_NUMBER`, `INVALID_CONFIG`,
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
`RECEIPT_NOT_FOUND`.
- One leaf class per code, all extending `BaseSmsError`.
- `BaseOpts`-typed constructors that accept `message`,
`context`, `cause`, `traceId`, with `pickOpts` stripping
undefined keys so the `exactOptionalPropertyTypes`
contract holds.
- `SmsInvalidConfigError` merges Zod issues into
`context.issues`.
## Usage: Basic
```ts
import {
SMS_CODES,
SmsRateLimitedError,
} from "@suaveplan/sms/error-codes";
try {
throw new SmsRateLimitedError({
context: { phoneNumber: "+15551234567" },
});
} catch (err) {
if (err instanceof SmsRateLimitedError) {
console.log(err.code === SMS_CODES.RATE_LIMITED);
}
}
```
## Usage: Advanced
```ts
import {
BaseSmsError,
SmsInvalidConfigError,
} from "@suaveplan/sms/error-codes";
function classifyAndLog(err: unknown): void {
if (err instanceof SmsInvalidConfigError) {
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
Record<string, unknown>
>) {
console.error("zod issue", issue);
}
return;
}
if (err instanceof BaseSmsError) {
console.error(`${err.code}: ${err.message}`, err.context ?? {});
}
}
```
## API Reference
- `SMS_CODES: Readonly<Record<Reason, "SMS.Reason">>` — frozen
registry returned from `defineCodes("SMS", [...])`.
- `SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES]` — union
of every fully-qualified code.
- `abstract class BaseSmsError extends SuaveplanError<SmsCode>` —
base class for every typed leaf error.
- Leaf classes (each takes a `BaseOpts` argument and forwards a
default messageKey for the i18n resolver):
`SmsInvalidNumberError`, `SmsInvalidConfigError`,
`SmsProviderNotConfiguredError`, `SmsProviderError`,
`SmsSendFailedError`, `SmsTemplateNotFoundError`,
`SmsRecipientOptedOutError`, `SmsRateLimitedError`,
`SmsForbiddenError`, `SmsConfigMissingError`,
`SmsScheduleCancelledError`, `SmsAbortedError`,
`SmsReceiptNotFoundError`.
## Implementation Notes
The leaf-class pattern (one class per code) was chosen over a
single generic class so callers can `instanceof Xxx` to discriminate
without inspecting `.code`. The `pickOpts` helper guards against
the `exactOptionalPropertyTypes` contract — copying a
`cause: undefined` into the super options table would fail to
typecheck against `SuaveplanErrorOptions`. Codes are reserved at
module load via `defineCodes` (which returns a frozen object) so
the registry cannot drift across module reloads.

View File

@@ -0,0 +1,211 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeliveryTracker } from "../delivery-tracker/delivery-tracker.js";
import { InMemorySmsProvider } from "../in-memory-provider/in-memory-provider.js";
import { RateLimiter } from "../rate-limiter/rate-limiter.js";
import { RetryPolicy } from "../retry/retry.js";
import { parseSmsMessage } from "../schemas/schemas.js";
import { SmsService } from "../service/sms-service.js";
import { TemplateRegistry } from "../templates/templates.js";
import {
SMS_CODES,
SmsAbortedError,
SmsConfigMissingError,
SmsForbiddenError,
SmsInvalidConfigError,
SmsInvalidNumberError,
SmsProviderError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsReceiptNotFoundError,
SmsRecipientOptedOutError,
SmsScheduleCancelledError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "./error-codes.js";
describe("SMS error-code registry", () => {
it("exposes the thirteen declared codes", { timeout: 5000 }, () => {
expect(Object.values(SMS_CODES).sort()).toEqual(
[
"SMS.ABORTED",
"SMS.CONFIG_MISSING",
"SMS.FORBIDDEN",
"SMS.INVALID_CONFIG",
"SMS.INVALID_NUMBER",
"SMS.PROVIDER_ERROR",
"SMS.PROVIDER_NOT_CONFIGURED",
"SMS.RATE_LIMITED",
"SMS.RECEIPT_NOT_FOUND",
"SMS.RECIPIENT_OPTED_OUT",
"SMS.SCHEDULE_CANCELLED",
"SMS.SEND_FAILED",
"SMS.TEMPLATE_NOT_FOUND",
].sort()
);
});
it("every SMS code is reachable from a production call site", {
timeout: 30_000,
}, async () => {
// SMS.PROVIDER_NOT_CONFIGURED
const noProvider = new SmsService();
await expect(
noProvider.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsProviderNotConfiguredError);
// SMS.INVALID_NUMBER
const svc = new SmsService({
provider: new InMemorySmsProvider(),
retry: { maxAttempts: 1 },
});
await expect(
svc.send({ to: "bad", body: "hi" })
).rejects.toBeInstanceOf(SmsInvalidNumberError);
// SMS.RECIPIENT_OPTED_OUT
const svc2 = new SmsService({
provider: new InMemorySmsProvider(),
retry: { maxAttempts: 1 },
});
svc2.optOut("+15551234567");
await expect(
svc2.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsRecipientOptedOutError);
// SMS.RATE_LIMITED
const svc3 = new SmsService({
provider: new InMemorySmsProvider(),
rateLimiter: { capacity: 1, refillPerSecond: 1 },
retry: { maxAttempts: 1 },
});
await svc3.send({ to: "+15551234567", body: "hi" });
await expect(
svc3.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsRateLimitedError);
// SMS.TEMPLATE_NOT_FOUND
const reg = new TemplateRegistry();
expect(() => reg.require("absent")).toThrow(SmsTemplateNotFoundError);
// SMS.INVALID_CONFIG
expect(() => parseSmsMessage({ to: "+1", body: "" })).toThrow(
SmsInvalidConfigError
);
// SMS.RECEIPT_NOT_FOUND
const tracker = new DeliveryTracker();
await expect(tracker.lookupOrFetch("absent")).rejects.toBeInstanceOf(
SmsReceiptNotFoundError
);
// SMS.ABORTED (pre-aborted)
const ctrl = new AbortController();
ctrl.abort();
const retry = new RetryPolicy({
maxAttempts: 1,
random: () => 0,
});
await expect(
retry.execute(async () => 1, { signal: ctrl.signal })
).rejects.toBeInstanceOf(SmsAbortedError);
// SMS.PROVIDER_ERROR (raw construct)
expect(() => {
throw new SmsProviderError();
}).toThrow(SmsProviderError);
// SMS.SEND_FAILED — exhausted retries with non-coded error
const svc4 = new SmsService({
provider: (() => {
const p = new InMemorySmsProvider();
p.failOn(() => true, new Error("transient"));
return p;
})(),
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 100, refillPerSecond: 100 },
});
await expect(
svc4.send({ to: "+15559999999", body: "hi" })
).rejects.toBeInstanceOf(SmsSendFailedError);
// SMS.CONFIG_MISSING — synthetic constructable
expect(() => {
throw new SmsConfigMissingError();
}).toThrow(SmsConfigMissingError);
// SMS.FORBIDDEN — synthetic constructable
expect(() => {
throw new SmsForbiddenError();
}).toThrow(SmsForbiddenError);
// SMS.SCHEDULE_CANCELLED — via cancel handle
const svc5 = new SmsService({
provider: new InMemorySmsProvider(),
});
const handle = svc5.scheduleAt(new Date(Date.now() + 60_000), {
to: "+15551234567",
body: "later",
});
handle.cancel();
expect(() => {
throw new SmsScheduleCancelledError();
}).toThrow(SmsScheduleCancelledError);
});
it("rate limiter is independent of the service", { timeout: 5000 }, () => {
const r = new RateLimiter({ capacity: 1, refillPerSecond: 1 });
expect(r.tryConsume("x")).toBe(true);
});
it("every error class accepts a custom message + context", {
timeout: 5000,
}, () => {
const ctx = { messageId: "m1" };
const errs = [
new SmsInvalidNumberError({ message: "custom", context: ctx }),
new SmsInvalidConfigError({ message: "custom", context: ctx }),
new SmsProviderNotConfiguredError({
message: "custom",
context: ctx,
}),
new SmsProviderError({ message: "custom", context: ctx }),
new SmsSendFailedError({ message: "custom", context: ctx }),
new SmsTemplateNotFoundError({ message: "custom", context: ctx }),
new SmsRecipientOptedOutError({
message: "custom",
context: ctx,
}),
new SmsRateLimitedError({ message: "custom", context: ctx }),
new SmsForbiddenError({ message: "custom", context: ctx }),
new SmsConfigMissingError({ message: "custom", context: ctx }),
new SmsScheduleCancelledError({
message: "custom",
context: ctx,
}),
new SmsAbortedError({ message: "custom", context: ctx }),
new SmsReceiptNotFoundError({ message: "custom", context: ctx }),
];
for (const e of errs) {
expect(e.message).toBe("custom");
expect(e.context).toEqual(ctx);
}
});
it("constructs with cause + traceId branches", { timeout: 5000 }, () => {
const cause = new Error("inner");
const e = new SmsSendFailedError({ cause, traceId: "t1" });
expect(e.cause).toBe(cause);
expect(e.traceId).toBe("t1");
});
it("SmsInvalidConfigError merges context + issues", {
timeout: 5000,
}, () => {
const e = new SmsInvalidConfigError({
context: { extra: 1 },
issues: [{ code: "x", path: [], message: "y" }],
});
expect(e.context?.["extra"]).toBe(1);
expect(e.context?.["issues"]).toBeDefined();
});
});

View File

@@ -0,0 +1,195 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/sms`. Every error thrown by
* the package surfaces one of these 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 SMS_CODES = defineCodes("SMS", [
"INVALID_NUMBER",
"INVALID_CONFIG",
"PROVIDER_NOT_CONFIGURED",
"PROVIDER_ERROR",
"SEND_FAILED",
"TEMPLATE_NOT_FOUND",
"RECIPIENT_OPTED_OUT",
"RATE_LIMITED",
"FORBIDDEN",
"CONFIG_MISSING",
"SCHEDULE_CANCELLED",
"ABORTED",
"RECEIPT_NOT_FOUND",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES];
/** Base class for every typed SMS error. */
export abstract class BaseSmsError extends SuaveplanError<SmsCode> {}
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;
}
/** Phone number is not a valid E.164 number. */
export class SmsInvalidNumberError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.INVALID_NUMBER,
opts.message ?? "sms.errors.invalidNumber",
pickOpts(opts)
);
}
}
/** Zod schema rejected a public input. */
export class SmsInvalidConfigError extends BaseSmsError {
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(
SMS_CODES.INVALID_CONFIG,
opts.message ?? "sms.errors.invalidConfig",
{ ...pickOpts(opts), context: ctx }
);
}
}
/** `send()` called before a provider was configured. */
export class SmsProviderNotConfiguredError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.PROVIDER_NOT_CONFIGURED,
opts.message ?? "sms.errors.providerNotConfigured",
pickOpts(opts)
);
}
}
/** Provider returned an unexpected error. */
export class SmsProviderError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.PROVIDER_ERROR,
opts.message ?? "sms.errors.providerError",
pickOpts(opts)
);
}
}
/** Send attempt exhausted retries. */
export class SmsSendFailedError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.SEND_FAILED,
opts.message ?? "sms.errors.sendFailed",
pickOpts(opts)
);
}
}
/** Template not found in the registry. */
export class SmsTemplateNotFoundError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.TEMPLATE_NOT_FOUND,
opts.message ?? "sms.errors.templateNotFound",
pickOpts(opts)
);
}
}
/** Recipient is on the opt-out registry. */
export class SmsRecipientOptedOutError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.RECIPIENT_OPTED_OUT,
opts.message ?? "sms.errors.recipientOptedOut",
pickOpts(opts)
);
}
}
/** Rate limiter rejected the send. */
export class SmsRateLimitedError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.RATE_LIMITED,
opts.message ?? "sms.errors.rateLimited",
pickOpts(opts)
);
}
}
/** Caller is not authorised to send through this provider. */
export class SmsForbiddenError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.FORBIDDEN,
opts.message ?? "sms.errors.forbidden",
pickOpts(opts)
);
}
}
/** No provider has been configured (legacy alias of PROVIDER_NOT_CONFIGURED). */
export class SmsConfigMissingError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.CONFIG_MISSING,
opts.message ?? "sms.errors.configMissing",
pickOpts(opts)
);
}
}
/** Schedule was cancelled before the timer fired. */
export class SmsScheduleCancelledError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.SCHEDULE_CANCELLED,
opts.message ?? "sms.errors.scheduleCancelled",
pickOpts(opts)
);
}
}
/** Operation was aborted via an `AbortSignal`. */
export class SmsAbortedError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.ABORTED,
opts.message ?? "sms.errors.aborted",
pickOpts(opts)
);
}
}
/** Delivery tracker miss with no provider fallback. */
export class SmsReceiptNotFoundError extends BaseSmsError {
public constructor(opts: BaseOpts = {}) {
super(
SMS_CODES.RECEIPT_NOT_FOUND,
opts.message ?? "sms.errors.receiptNotFound",
pickOpts(opts)
);
}
}

View File

@@ -0,0 +1,18 @@
export type { SmsCode } from "./error-codes.js";
export {
BaseSmsError,
SMS_CODES,
SmsAbortedError,
SmsConfigMissingError,
SmsForbiddenError,
SmsInvalidConfigError,
SmsInvalidNumberError,
SmsProviderError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsReceiptNotFoundError,
SmsRecipientOptedOutError,
SmsScheduleCancelledError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "./error-codes.js";

View File

@@ -0,0 +1,101 @@
# `in-memory-provider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemorySmsProvider` is the production-quality in-process
`SmsProvider` implementation that ships with `@suaveplan/sms`.
It satisfies the abstract `SmsProvider` contract — `name`,
`send`, `getStatus` — by recording every message into an
in-memory log and returning configurable delivery receipts. It
is the canonical test double for integration tests, demo apps,
and host applications that want to exercise the send pipeline
without a real SMS carrier.
This module replaces the upstream pattern of ad-hoc anonymous
mocks. Every test in the package uses this provider — there are
no `vi.fn()` stubs of the `SmsProvider` contract.
## Features
- Implements the full `SmsProvider` contract (`name`, `send`,
`getStatus`).
- `sent: SmsMessage[]` — log of every outgoing message, in
order.
- `reports: SmsDeliveryReport[]` — parallel log of every receipt
returned.
- Configurable `defaultStatus` (`queued` / `sent` / `delivered`
/ `failed` / `undelivered`) returned by `send`.
- `setStatus(messageId, status)` — transitions a previously-sent
message's status for `getStatus` callers.
- `failOn(predicate, error?)` — configure a per-message failure
predicate so tests simulate provider rejections without
swapping the implementation.
- Injectable `Clock` for deterministic receipt timestamps.
- Injectable `IdGenerator` for predictable message IDs.
- AbortSignal-aware — a pre-aborted signal rejects with
`Error("aborted")` so the upstream retry can wrap it.
## Usage: Basic
```ts
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
const provider = new InMemorySmsProvider();
const report = await provider.send({ to: "+15551234567", body: "hi" });
console.log(provider.sent[0]?.body); // "hi"
console.log(report.status); // "sent"
```
## Usage: Advanced
```ts
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
let n = 0;
const provider = new InMemorySmsProvider({
name: "twilio-fake",
defaultStatus: "queued",
idGenerator: () => `seed_${n++}`,
clock: () => 1_700_000_000_000,
});
provider.failOn(
(msg) => msg.to === "+15550000000",
new Error("blocked-by-carrier")
);
const r = await provider.send({ to: "+15551234567", body: "ping" });
provider.setStatus(r.messageId, "delivered");
console.log((await provider.getStatus(r.messageId)).status); // "delivered"
```
## API Reference
- `class InMemorySmsProvider implements SmsProvider`
- `readonly name: string`
- `readonly sent: SmsMessage[]`
- `readonly reports: SmsDeliveryReport[]`
- `send(message, options?): Promise<SmsDeliveryReport>`
- `getStatus(messageId): Promise<SmsDeliveryReport>`
- `setStatus(messageId, status): void` (throws on unknown id)
- `failOn(predicate, error?): void`
- `clearFailures(): void`
- `clear(): void`
- `interface InMemorySmsProviderOptions`
- `name?`, `defaultStatus?`, `idGenerator?`, `clock?`
- `type SmsFailurePredicate = (m: SmsMessage) => boolean`
## Implementation Notes
The provider stores two parallel arrays — `sent` (the incoming
`SmsMessage`) and `reports` (the outgoing receipts) — so test
assertions on the rendered body work even when the service has
substituted template variables. The default `idGenerator`
combines a monotonic counter and the clock so collisions are
impossible across instances. AbortSignal rejection uses a plain
`Error("aborted")`; `SmsService.send` wraps it into
`SmsAbortedError` after rate-limiter / retry layers run.

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SmsMessage } from "../types/sms.types.js";
import { InMemorySmsProvider } from "./in-memory-provider.js";
const message: SmsMessage = { to: "+15551234567", body: "hi" };
describe("InMemorySmsProvider", () => {
it("send appends to log", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
const report = await provider.send(message);
expect(provider.sent).toHaveLength(1);
expect(provider.sent[0]?.to).toBe("+15551234567");
expect(provider.sent[0]?.body).toBe("hi");
expect(provider.reports).toHaveLength(1);
expect(report.status).toBe("sent");
expect(report.messageId).toMatch(/^inmem_/);
});
it("respects defaultStatus", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider({ defaultStatus: "queued" });
const report = await provider.send(message);
expect(report.status).toBe("queued");
});
it("respects custom name and id generator", {
timeout: 5000,
}, async () => {
let n = 0;
const provider = new InMemorySmsProvider({
name: "twilio-fake",
idGenerator: () => `fixed_${n++}`,
});
expect(provider.name).toBe("twilio-fake");
const r1 = await provider.send(message);
const r2 = await provider.send(message);
expect(r1.messageId).toBe("fixed_0");
expect(r2.messageId).toBe("fixed_1");
});
it("respects injected clock for timestamps", {
timeout: 5000,
}, async () => {
const provider = new InMemorySmsProvider({ clock: () => 5000 });
const report = await provider.send(message);
expect(report.timestamp.getTime()).toBe(5000);
});
it("failOn triggers", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
provider.failOn((m) => m.to === "+15550000000", new Error("boom"));
await expect(
provider.send({ to: "+15550000000", body: "x" })
).rejects.toThrow("boom");
// does not affect other recipients
await expect(provider.send(message)).resolves.toBeDefined();
});
it("failOn without custom error uses default message", {
timeout: 5000,
}, async () => {
const provider = new InMemorySmsProvider();
provider.failOn(() => true);
await expect(provider.send(message)).rejects.toThrow(
"in-memory send failed"
);
});
it("clearFailures removes the predicate", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
provider.failOn(() => true);
provider.clearFailures();
await expect(provider.send(message)).resolves.toBeDefined();
});
it("setStatus transitions delivery state", {
timeout: 5000,
}, async () => {
const provider = new InMemorySmsProvider();
const r1 = await provider.send(message);
provider.setStatus(r1.messageId, "delivered");
const status = await provider.getStatus(r1.messageId);
expect(status.status).toBe("delivered");
expect(status.to).toBe("+15551234567");
});
it("setStatus throws on unknown id", { timeout: 5000 }, () => {
const provider = new InMemorySmsProvider();
expect(() => provider.setStatus("absent", "delivered")).toThrow(
/Unknown messageId/
);
});
it("getStatus throws on unknown id", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
await expect(provider.getStatus("absent")).rejects.toThrow(
/Unknown messageId/
);
});
it("aborted signal rejects send", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
const ctrl = new AbortController();
ctrl.abort();
await expect(
provider.send(message, { signal: ctrl.signal })
).rejects.toThrow("aborted");
});
it("clear empties the log", { timeout: 5000 }, async () => {
const provider = new InMemorySmsProvider();
await provider.send(message);
provider.clear();
expect(provider.sent).toHaveLength(0);
});
});

View File

@@ -0,0 +1,121 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import type {
SmsDeliveryReport,
SmsDeliveryStatus,
SmsMessage,
SmsProvider,
SmsSendCallOptions,
} from "../types/sms.types.js";
const defaultClock: Clock = () => Date.now();
/** Construct options for `InMemorySmsProvider`. */
export interface InMemorySmsProviderOptions {
/** Provider name reported via `SmsProvider.name`. */
name?: string;
/** Status returned by `send` (defaults to `"sent"`). */
defaultStatus?: SmsDeliveryStatus;
/** Injectable message-id factory. */
idGenerator?: IdGenerator;
/** Injectable monotonic clock used for receipt timestamps. */
clock?: Clock;
}
/** Predicate evaluated against an outgoing message. */
export type SmsFailurePredicate = (message: SmsMessage) => boolean;
/**
* Real `SmsProvider` implementation backed by an in-memory message log.
* The provider is the canonical test double — tests configure failure
* predicates, status transitions, and seeded message IDs without any
* network IO.
*/
export class InMemorySmsProvider implements SmsProvider {
public readonly name: string;
public readonly sent: SmsMessage[] = [];
public readonly reports: SmsDeliveryReport[] = [];
private readonly statusById = new Map<string, SmsDeliveryStatus>();
private readonly defaultStatus: SmsDeliveryStatus;
private readonly idGenerator: IdGenerator;
private readonly clock: Clock;
private failurePredicate: SmsFailurePredicate | undefined;
private failureError: Error | undefined;
public constructor(options: InMemorySmsProviderOptions = {}) {
this.name = options.name ?? "in-memory";
this.defaultStatus = options.defaultStatus ?? "sent";
this.clock = options.clock ?? defaultClock;
let counter = 0;
this.idGenerator =
options.idGenerator ??
((): string => {
counter += 1;
return `inmem_${counter.toString(36)}_${this.clock().toString(36)}`;
});
}
public async send(
message: SmsMessage,
options: SmsSendCallOptions = {}
): Promise<SmsDeliveryReport> {
if (options.signal?.aborted) {
throw new Error("aborted");
}
if (this.failurePredicate?.(message)) {
const err = this.failureError ?? new Error("in-memory send failed");
throw err;
}
const messageId = this.idGenerator();
const report: SmsDeliveryReport = {
messageId,
to: message.to,
status: this.defaultStatus,
timestamp: new Date(this.clock()),
};
this.sent.push(message);
this.reports.push(report);
this.statusById.set(messageId, this.defaultStatus);
return report;
}
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
const status = this.statusById.get(messageId);
const last = this.reports.find((r) => r.messageId === messageId);
if (status === undefined || last === undefined) {
throw new Error(`Unknown messageId: ${messageId}`);
}
return {
messageId,
to: last.to,
status,
timestamp: new Date(this.clock()),
};
}
/** Transition the delivery status of a previously-sent message. */
public setStatus(messageId: string, status: SmsDeliveryStatus): void {
if (!this.statusById.has(messageId)) {
throw new Error(`Unknown messageId: ${messageId}`);
}
this.statusById.set(messageId, status);
}
/** Configure the provider to throw `error` for every message matching `predicate`. */
public failOn(predicate: SmsFailurePredicate, error?: Error): void {
this.failurePredicate = predicate;
this.failureError = error;
}
/** Drop all configured failure predicates. */
public clearFailures(): void {
this.failurePredicate = undefined;
this.failureError = undefined;
}
/** Drop the message log and status table. */
public clear(): void {
this.sent.length = 0;
this.reports.length = 0;
this.statusById.clear();
}
}

View File

@@ -0,0 +1,5 @@
export type {
InMemorySmsProviderOptions,
SmsFailurePredicate,
} from "./in-memory-provider.js";
export { InMemorySmsProvider } from "./in-memory-provider.js";

View File

@@ -0,0 +1,99 @@
// Side-effect: registers the SMS_* legacy metadata table at import time.
import "./legacy-metadata/index.js";
export { DeliveryTracker } from "./delivery-tracker/delivery-tracker.js";
export type { SmsCode } from "./error-codes/error-codes.js";
export {
BaseSmsError,
SMS_CODES,
SmsAbortedError,
SmsConfigMissingError,
SmsForbiddenError,
SmsInvalidConfigError,
SmsInvalidNumberError,
SmsProviderError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsReceiptNotFoundError,
SmsRecipientOptedOutError,
SmsScheduleCancelledError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "./error-codes/error-codes.js";
export type {
InMemorySmsProviderOptions,
SmsFailurePredicate,
} from "./in-memory-provider/in-memory-provider.js";
export { InMemorySmsProvider } from "./in-memory-provider/in-memory-provider.js";
export type {
SmsErrorCode,
SmsErrorContext,
SmsErrorOptions,
} from "./legacy-errors/legacy-errors.js";
export { SmsError } from "./legacy-errors/legacy-errors.js";
export type {
SmsCodeMetadata,
SmsCodeSeverity,
SmsRegisteredErrorCode,
SmsRetryHint,
} from "./legacy-metadata/legacy-metadata.js";
export {
registerSmsErrorCodes,
SMS_ERROR_METADATA,
} from "./legacy-metadata/legacy-metadata.js";
export { OptOutRegistry } from "./opt-out/opt-out.js";
export {
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
recordSmsSent,
refreshSmsMeters,
} from "./otel/metrics.js";
export { RateLimiter } from "./rate-limiter/rate-limiter.js";
export { type RetryHook, RetryPolicy } from "./retry/retry.js";
export type {
HonoLike,
ProblemDetails,
} from "./routes/problem-json.js";
export {
PROBLEM_JSON_CONTENT_TYPE,
respondProblemJson,
} from "./routes/problem-json.js";
export { MessageScheduler } from "./scheduler/scheduler.js";
export {
parseSmsDeliveryReport,
parseSmsMessage,
parseSmsTemplate,
SmsDeliveryReportSchema,
SmsDeliveryStatusSchema,
SmsMessageSchema,
SmsTemplateSchema,
} from "./schemas/schemas.js";
export type { SmsBusMap } from "./service/bus-map.js";
export {
LegacyEmitter,
type SmsLegacyListener,
} from "./service/legacy-emitter.js";
export { type SmsSendOptions, SmsService } from "./service/sms-service.js";
export { renderTemplate, TemplateRegistry } from "./templates/templates.js";
export type {
DeliveryTrackerOptions,
MessageSchedulerOptions,
OptOutEntry,
RateLimiterOptions,
RetryPolicyOptions,
ScheduledMessage,
ScheduleHandle,
SmsDeliveryReport,
SmsDeliveryStatus,
SmsEventMap,
SmsMessage,
SmsProvider,
SmsRateLimitedEvent,
SmsRetryEvent,
SmsScheduledEvent,
SmsSendCallOptions,
SmsServiceOptions,
SmsTemplate,
} from "./types/sms.types.js";
export { E164_REGEX, isValidE164 } from "./validation/phone.js";

View File

@@ -0,0 +1,6 @@
export type {
SmsErrorCode,
SmsErrorContext,
SmsErrorOptions,
} from "./legacy-errors.js";
export { SmsError } from "./legacy-errors.js";

View File

@@ -0,0 +1,93 @@
# `legacy-errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`legacy-errors` preserves the upstream `@suaveplan/sms` v1
public surface — the `SmsError` class, the `SmsErrorCode`
string-union, the five static factory methods, the
`SmsErrorContext` shape, the `SmsErrorOptions` shape, and the
`isSmsError` type guard — so existing callers compile against
the package with zero source changes. Internally the
class extends `SuaveplanError` so it integrates with the
error stack (cause walking, OTel span events,
redaction, the i18n resolver hook).
## Features
- Upstream-compatible `SmsErrorCode` union with the five legacy
string codes (`SMS_INVALID_PHONE_NUMBER`, `SMS_SEND_FAILED`,
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
`SMS_PROVIDER_NOT_CONFIGURED`).
- `SmsError` class extending `SuaveplanError<SmsErrorCode>`
so it picks up cause walking, OTel adapter, the i18n
resolver, and `WireError` serialization.
- Five static factory methods, each populating `context` with
the upstream field shape (`phoneNumber`, `messageId`,
`templateId`).
- `isSmsError` instance check.
## Usage: Basic
```ts
import { SmsError } from "@suaveplan/sms/legacy-errors";
try {
throw SmsError.invalidPhoneNumber("not-a-number");
} catch (err) {
if (SmsError.isSmsError(err)) {
console.log(err.code, err.context?.phoneNumber);
}
}
```
## Usage: Advanced
```ts
import { SmsError } from "@suaveplan/sms/legacy-errors";
async function sendOnce(): Promise<void> {
try {
// ... call provider ...
throw new Error("network");
} catch (cause) {
throw SmsError.sendFailed("msg_42", cause as Error);
}
}
try {
await sendOnce();
} catch (err) {
if (SmsError.isSmsError(err) && err.code === "SMS_SEND_FAILED") {
console.error("send failed:", err.context?.messageId, err.cause);
}
}
```
## API Reference
- `type SmsErrorCode` — union of the five legacy string codes.
- `interface SmsErrorContext` — `phoneNumber?`, `templateId?`,
`providerId?`, `messageId?`, plus an open `Record<string, unknown>`
index signature.
- `interface SmsErrorOptions` — `message`, `code`, `context?`,
`cause?`.
- `class SmsError extends SuaveplanError<SmsErrorCode>` — public
constructor accepting `SmsErrorOptions`.
- Static factories: `invalidPhoneNumber(phoneNumber)`,
`sendFailed(messageId, cause?)`, `templateNotFound(templateId)`,
`recipientOptedOut(phoneNumber)`, `providerNotConfigured()`.
- Static type guard: `isSmsError(value): value is SmsError`.
## Implementation Notes
`@suaveplan/error` has a `protected` constructor on
`SuaveplanError`; subclasses must declare a `public` constructor
that calls `super(code, message, options)`. The legacy class
preserves the upstream argument order (`{message, code, ...}`)
so existing call sites remain identical, then forwards to the
standard super constructor. `sendFailed` includes a conditional
add of `cause` to satisfy `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SmsError } from "./legacy-errors.js";
describe("SmsError legacy class", () => {
it("factory methods return the right code", { timeout: 5000 }, () => {
expect(SmsError.invalidPhoneNumber("bad").code).toBe(
"SMS_INVALID_PHONE_NUMBER"
);
expect(SmsError.sendFailed("m1").code).toBe("SMS_SEND_FAILED");
expect(SmsError.templateNotFound("t").code).toBe(
"SMS_TEMPLATE_NOT_FOUND"
);
expect(SmsError.recipientOptedOut("+15551234567").code).toBe(
"SMS_RECIPIENT_OPTED_OUT"
);
expect(SmsError.providerNotConfigured().code).toBe(
"SMS_PROVIDER_NOT_CONFIGURED"
);
});
it("isSmsError narrows the type", { timeout: 5000 }, () => {
const e = SmsError.invalidPhoneNumber("bad");
expect(SmsError.isSmsError(e)).toBe(true);
expect(SmsError.isSmsError(new Error("x"))).toBe(false);
expect(SmsError.isSmsError("string")).toBe(false);
});
it("carries phoneNumber context for invalidPhoneNumber", {
timeout: 5000,
}, () => {
const e = SmsError.invalidPhoneNumber("bad-phone");
expect(e.context).toEqual({ phoneNumber: "bad-phone" });
});
it("carries messageId context for sendFailed", { timeout: 5000 }, () => {
const cause = new Error("net");
const e = SmsError.sendFailed("m1", cause);
expect(e.context).toEqual({ messageId: "m1" });
expect(e.cause).toBe(cause);
});
it("sendFailed without cause omits cause property", {
timeout: 5000,
}, () => {
const e = SmsError.sendFailed("m1");
expect(e.cause).toBeUndefined();
});
it("templateNotFound carries templateId context", {
timeout: 5000,
}, () => {
const e = SmsError.templateNotFound("welcome");
expect(e.context).toEqual({ templateId: "welcome" });
});
it("recipientOptedOut carries phoneNumber context", {
timeout: 5000,
}, () => {
const e = SmsError.recipientOptedOut("+15551234567");
expect(e.context).toEqual({ phoneNumber: "+15551234567" });
});
it("constructor with custom context", { timeout: 5000 }, () => {
const e = new SmsError({
message: "custom",
code: "SMS_SEND_FAILED",
context: { providerId: "twilio" },
});
expect(e.name).toBe("SmsError");
expect(e.context).toEqual({ providerId: "twilio" });
});
it("constructor without context defaults to empty object", {
timeout: 5000,
}, () => {
const e = new SmsError({
message: "custom",
code: "SMS_SEND_FAILED",
});
expect(e.context).toEqual({});
});
});

View File

@@ -0,0 +1,105 @@
import { SuaveplanError } from "@suaveplan/error";
/**
* Upstream-compatible SMS error class.
*
* Preserves the upstream `@suaveplan/sms` v1 public surface — the
* `SmsErrorCode` string union, the static factory methods
* (`invalidPhoneNumber`, `sendFailed`, `templateNotFound`,
* `recipientOptedOut`, `providerNotConfigured`), the `isSmsError` type
* guard, the `SmsErrorContext` shape, and the `SmsErrorOptions` shape —
* so existing callers compile against genesis with zero source changes.
* Internally the class extends `SuaveplanError` so it integrates with
* the genesis error stack (cause walking, OTel span events, redaction,
* i18n resolver hook).
*
* @module legacy-errors/legacy-errors
*/
/** Upstream-compatible SMS error code union. */
export type SmsErrorCode =
| "SMS_INVALID_PHONE_NUMBER"
| "SMS_SEND_FAILED"
| "SMS_TEMPLATE_NOT_FOUND"
| "SMS_RECIPIENT_OPTED_OUT"
| "SMS_PROVIDER_NOT_CONFIGURED";
/** Context data for `SmsError` instances. */
export interface SmsErrorContext extends Record<string, unknown> {
phoneNumber?: string;
templateId?: string;
providerId?: string;
messageId?: string;
}
/** Options for constructing an `SmsError`. */
export interface SmsErrorOptions {
message: string;
code: SmsErrorCode;
context?: SmsErrorContext;
cause?: Error;
}
/** Upstream-compatible SMS error class. */
export class SmsError extends SuaveplanError<SmsErrorCode> {
public constructor(options: SmsErrorOptions) {
const superOpts: {
context: Readonly<Record<string, unknown>>;
cause?: unknown;
} = { context: options.context ?? {} };
if (options.cause !== undefined) superOpts.cause = options.cause;
super(options.code, options.message, superOpts);
this.name = "SmsError";
}
/** Create an invalid phone number error. */
public static invalidPhoneNumber(phoneNumber: string): SmsError {
return new SmsError({
message: `Invalid phone number: ${phoneNumber}`,
code: "SMS_INVALID_PHONE_NUMBER",
context: { phoneNumber },
});
}
/** Create a send-failed error. */
public static sendFailed(messageId: string, cause?: Error): SmsError {
const opts: SmsErrorOptions = {
message: `Failed to send SMS: ${messageId}`,
code: "SMS_SEND_FAILED",
context: { messageId },
};
if (cause !== undefined) opts.cause = cause;
return new SmsError(opts);
}
/** Create a template-not-found error. */
public static templateNotFound(templateId: string): SmsError {
return new SmsError({
message: `SMS template not found: ${templateId}`,
code: "SMS_TEMPLATE_NOT_FOUND",
context: { templateId },
});
}
/** Create a recipient-opted-out error. */
public static recipientOptedOut(phoneNumber: string): SmsError {
return new SmsError({
message: `Recipient has opted out: ${phoneNumber}`,
code: "SMS_RECIPIENT_OPTED_OUT",
context: { phoneNumber },
});
}
/** Create a provider-not-configured error. */
public static providerNotConfigured(): SmsError {
return new SmsError({
message: "No SMS provider has been configured",
code: "SMS_PROVIDER_NOT_CONFIGURED",
});
}
/** Type guard for `SmsError` instances. */
public static isSmsError(value: unknown): value is SmsError {
return value instanceof SmsError;
}
}

View File

@@ -0,0 +1,10 @@
export type {
SmsCodeMetadata,
SmsCodeSeverity,
SmsRegisteredErrorCode,
SmsRetryHint,
} from "./legacy-metadata.js";
export {
registerSmsErrorCodes,
SMS_ERROR_METADATA,
} from "./legacy-metadata.js";

View File

@@ -0,0 +1,85 @@
# `legacy-metadata`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`legacy-metadata` preserves the upstream `SMS_ERROR_METADATA`
table — the per-code mapping of `httpStatus`, `severity`, retry
hint, `userMessageKey`, `category`, and `description` — so
existing callers that probe the table to drive their own
problem+json responses, retry policies, or i18n catalogues
continue to work. The upstream package registered the table on a
process-wide `ErrorCodes` singleton; this package carries no such
singleton, so `registerSmsErrorCodes()` is reframed as a
no-op-friendly getter that returns the frozen table. Callers can
plug the returned table into whatever registry their host wires
up.
## Features
- Frozen `SMS_ERROR_METADATA` mapping with the six upstream
entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
`SMS_FORBIDDEN`).
- `SmsCodeMetadata` / `SmsCodeSeverity` / `SmsRetryHint` shapes
exposed as type contracts.
- `registerSmsErrorCodes()` idempotent helper returning the
table.
- Eagerly evaluates on import to mirror the upstream load-time
side effect.
## Usage: Basic
```ts
import { SMS_ERROR_METADATA } from "@suaveplan/sms/legacy-metadata";
const meta = SMS_ERROR_METADATA.SMS_RATE_LIMITED;
console.log(meta.httpStatus); // 429
console.log(meta.retry?.maxAttempts); // 5
```
## Usage: Advanced
```ts
import {
registerSmsErrorCodes,
type SmsCodeMetadata,
} from "@suaveplan/sms/legacy-metadata";
// Host wires the package metadata into its own registry
const table = registerSmsErrorCodes();
for (const [code, meta] of Object.entries(table) as [
string,
SmsCodeMetadata
][]) {
myHostRegistry.upsert(code, meta);
}
```
## API Reference
- `type SmsCodeSeverity = "info" | "warning" | "error" | "fatal"`.
- `interface SmsRetryHint` — `retryable: boolean`,
`strategy?: "linear" | "exponential"`, `maxAttempts?: number`.
- `interface SmsCodeMetadata` — `httpStatus`, `severity`,
`retry?`, `retryable?`, `userMessageKey`, `category`,
`description`.
- `type SmsRegisteredErrorCode` — the six upstream string codes.
- `const SMS_ERROR_METADATA: Readonly<Record<SmsRegisteredErrorCode,
SmsCodeMetadata>>` — frozen mapping.
- `function registerSmsErrorCodes(): Readonly<Record<SmsRegisteredErrorCode,
SmsCodeMetadata>>` — returns the table; idempotent.
## Implementation Notes
The table is frozen via `Object.freeze` so neither the
top-level dictionary nor the eager-registration step can mutate
entries downstream. The function returns the same table by
reference — callers needing a defensive copy should clone
explicitly. The eager call at module load mirrors the upstream
`registerSmsErrorCodes()` side effect that ran at v1 import
time, so a consumer that previously relied on the function being
called for them still sees the same behaviour .

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
registerSmsErrorCodes,
SMS_ERROR_METADATA,
} from "./legacy-metadata.js";
describe("legacy SMS_ERROR_METADATA", () => {
it("every metadata entry preserves the upstream shape", {
timeout: 5000,
}, () => {
expect(SMS_ERROR_METADATA.SMS_RATE_LIMITED).toEqual({
httpStatus: 429,
severity: "warning",
retry: { retryable: true, strategy: "linear", maxAttempts: 5 },
userMessageKey: "sms.errors.rateLimited",
category: "sms.throttle",
description:
"The SMS provider rate limit has been exceeded; the request may be retried after a back-off period.",
});
expect(SMS_ERROR_METADATA.SMS_SEND_FAILED.httpStatus).toBe(502);
expect(SMS_ERROR_METADATA.SMS_PROVIDER_ERROR.httpStatus).toBe(502);
expect(SMS_ERROR_METADATA.SMS_INVALID_NUMBER.httpStatus).toBe(400);
expect(SMS_ERROR_METADATA.SMS_CONFIG_MISSING.severity).toBe("fatal");
expect(SMS_ERROR_METADATA.SMS_FORBIDDEN.httpStatus).toBe(403);
});
it("registerSmsErrorCodes returns the metadata table", {
timeout: 5000,
}, () => {
const out = registerSmsErrorCodes();
expect(out).toBe(SMS_ERROR_METADATA);
});
it("is frozen at the top level", { timeout: 5000 }, () => {
expect(Object.isFrozen(SMS_ERROR_METADATA)).toBe(true);
});
it("registerSmsErrorCodes is idempotent", { timeout: 5000 }, () => {
const a = registerSmsErrorCodes();
const b = registerSmsErrorCodes();
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,120 @@
/**
* Upstream-compatible error-metadata registry for `@suaveplan/sms`.
*
* The upstream package registered six SMS error codes with a process-wide
* `ErrorCodes` singleton in `@suaveplan/error` that exposed
* `httpStatus`, `severity`, `retry`, `userMessageKey`, `category`, and
* `description` per code. Genesis `@suaveplan/error` does not ship a
* runtime registry — code metadata lives in the throwing class. To
* preserve the upstream contract for downstream consumers that still
* import `SMS_ERROR_METADATA` and `registerSmsErrorCodes`, this module
* exports the metadata table verbatim and provides a `registerSmsErrorCodes`
* function that returns the table (so callers can plug it into whatever
* registry their host wires up).
*
* @module legacy-metadata/legacy-metadata
*/
/** Severity levels mirrored from the upstream registry. */
export type SmsCodeSeverity = "info" | "warning" | "error" | "fatal";
/** Retry hint shape mirrored from the upstream registry. */
export interface SmsRetryHint {
retryable: boolean;
strategy?: "linear" | "exponential";
maxAttempts?: number;
}
/** Per-code metadata mirrored from the upstream registry. */
export interface SmsCodeMetadata {
httpStatus: number;
severity: SmsCodeSeverity;
retry?: SmsRetryHint;
retryable?: boolean;
userMessageKey: string;
category: string;
description: string;
}
/** Codes registered by `SMS_ERROR_METADATA`. */
export type SmsRegisteredErrorCode =
| "SMS_SEND_FAILED"
| "SMS_PROVIDER_ERROR"
| "SMS_INVALID_NUMBER"
| "SMS_RATE_LIMITED"
| "SMS_CONFIG_MISSING"
| "SMS_FORBIDDEN";
/** Mapping of every `SmsRegisteredErrorCode` to its registry metadata. */
export const SMS_ERROR_METADATA: Readonly<
Record<SmsRegisteredErrorCode, SmsCodeMetadata>
> = Object.freeze({
SMS_SEND_FAILED: {
httpStatus: 502,
severity: "error",
retry: { retryable: true, strategy: "exponential", maxAttempts: 3 },
userMessageKey: "sms.errors.sendFailed",
category: "sms.delivery",
description: "The SMS message could not be delivered by the provider.",
},
SMS_PROVIDER_ERROR: {
httpStatus: 502,
severity: "error",
retry: { retryable: true, strategy: "exponential", maxAttempts: 3 },
userMessageKey: "sms.errors.providerError",
category: "sms.provider",
description: "The upstream SMS provider returned an unexpected error.",
},
SMS_INVALID_NUMBER: {
httpStatus: 400,
severity: "warning",
retryable: false,
userMessageKey: "sms.errors.invalidNumber",
category: "sms.validation",
description:
"The destination phone number is not a valid E.164 number.",
},
SMS_RATE_LIMITED: {
httpStatus: 429,
severity: "warning",
retry: { retryable: true, strategy: "linear", maxAttempts: 5 },
userMessageKey: "sms.errors.rateLimited",
category: "sms.throttle",
description:
"The SMS provider rate limit has been exceeded; the request may be retried after a back-off period.",
},
SMS_CONFIG_MISSING: {
httpStatus: 500,
severity: "fatal",
retryable: false,
userMessageKey: "sms.errors.configMissing",
category: "sms.config",
description:
"No SMS provider has been configured; the application cannot send messages.",
},
SMS_FORBIDDEN: {
httpStatus: 403,
severity: "warning",
retryable: false,
userMessageKey: "sms.errors.forbidden",
category: "sms.auth",
description:
"The caller is not authorised to send SMS messages to this recipient or via this provider.",
},
});
/**
* Return the SMS error-code metadata table. Upstream installations could
* pass the returned table to their own `ErrorCodes.registerMany(...)`
* call; on genesis the table is the source of truth and the function
* is idempotent.
*/
export function registerSmsErrorCodes(): Readonly<
Record<SmsRegisteredErrorCode, SmsCodeMetadata>
> {
return SMS_ERROR_METADATA;
}
// Eagerly evaluate on first import to mirror upstream's load-time side
// effect; the call returns the frozen metadata table.
registerSmsErrorCodes();

View File

@@ -0,0 +1 @@
export { OptOutRegistry } from "./opt-out.js";

View File

@@ -0,0 +1,87 @@
# `opt-out`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`opt-out` is the in-memory opt-out registry that `SmsService`
consults before every outbound message. Recipients on the
registry receive no further messages — `service.send` throws
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
Entries can be scoped to a channel so transactional messages
remain deliverable when only marketing has been opted out by the
host application.
The registry is intentionally pluggable but ships as an in-process
`Map` so unit tests and small deployments need no external state;
production hosts that share opt-out across processes can wrap
`OptOutRegistry` in their own persistence layer.
## Features
- `add(phoneNumber, channel?)` — records the opt-out with a
timestamp from an injectable `Clock`.
- `remove(phoneNumber)` — opt-in helper.
- `has(phoneNumber)` and `get(phoneNumber)` — predicate +
detail accessors.
- `list()` snapshot, `size()`, and `clear()` for bulk operations.
- `importEntries(entries)` — bulk import with overwrite-by-id
semantics for syncing from an external source.
## Usage: Basic
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
const reg = new OptOutRegistry();
reg.add("+15551234567", "marketing");
console.log(reg.has("+15551234567")); // true
reg.remove("+15551234567");
```
## Usage: Advanced
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
// Inject a deterministic clock for tests; importEntries
// reseeds the registry from a downstream cache.
const reg = new OptOutRegistry({ clock: () => 1_700_000_000_000 });
reg.importEntries([
{
phoneNumber: "+15550000001",
optedOutAt: new Date(1_700_000_000_000),
channel: "marketing",
},
{
phoneNumber: "+15550000002",
optedOutAt: new Date(1_700_000_000_000),
},
]);
console.log(reg.size()); // 2
```
## API Reference
- `class OptOutRegistry`
- `constructor(options?: { clock?: Clock })`
- `add(phoneNumber: string, channel?: string): OptOutEntry`
- `remove(phoneNumber: string): boolean`
- `has(phoneNumber: string): boolean`
- `get(phoneNumber: string): OptOutEntry | undefined`
- `list(): ReadonlyArray<OptOutEntry>`
- `importEntries(entries: ReadonlyArray<OptOutEntry>): void`
- `clear(): void`
- `size(): number`
## Implementation Notes
The registry stores `OptOutEntry` records keyed by the verbatim
phone number — no normalisation. Callers SHOULD pass E.164
strings; mismatched formats will fail to match. `add` overwrites
existing entries by `phoneNumber`, so a caller switching the
`channel` on an existing opt-out simply re-adds. The `clock`
defaults to `Date.now`, deterministic only when explicitly
injected.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OptOutRegistry } from "./opt-out.js";
describe("OptOutRegistry", () => {
it("add/remove/has/list/clear", { timeout: 5000 }, () => {
const reg = new OptOutRegistry({ clock: () => 1000 });
const entry = reg.add("+15551234567", "marketing");
expect(entry.phoneNumber).toBe("+15551234567");
expect(entry.channel).toBe("marketing");
expect(entry.optedOutAt.getTime()).toBe(1000);
expect(reg.has("+15551234567")).toBe(true);
expect(reg.size()).toBe(1);
expect(reg.list()).toHaveLength(1);
expect(reg.get("+15551234567")?.channel).toBe("marketing");
expect(reg.remove("+15551234567")).toBe(true);
expect(reg.has("+15551234567")).toBe(false);
expect(reg.remove("+15551234567")).toBe(false);
});
it("omits channel when undefined", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
const entry = reg.add("+15551234567");
expect(entry.channel).toBeUndefined();
});
it("uses Date.now when no clock injected", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
const before = Date.now();
const entry = reg.add("+15551234567");
const after = Date.now();
expect(entry.optedOutAt.getTime()).toBeGreaterThanOrEqual(before);
expect(entry.optedOutAt.getTime()).toBeLessThanOrEqual(after);
});
it("importEntries overwrites existing", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
reg.add("+15551234567", "old");
reg.importEntries([
{
phoneNumber: "+15551234567",
optedOutAt: new Date(2000),
channel: "new",
},
{ phoneNumber: "+15559999999", optedOutAt: new Date(3000) },
]);
expect(reg.get("+15551234567")?.channel).toBe("new");
expect(reg.has("+15559999999")).toBe(true);
expect(reg.size()).toBe(2);
});
it("clear drops every entry", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
reg.add("+15551234567");
reg.add("+15559999999");
reg.clear();
expect(reg.size()).toBe(0);
});
});

View File

@@ -0,0 +1,62 @@
import type { Clock } from "@suaveplan/types";
import type { OptOutEntry } from "../types/sms.types.js";
const defaultClock: Clock = () => Date.now();
/** In-memory opt-out registry used by `SmsService`. */
export class OptOutRegistry {
private readonly entries = new Map<string, OptOutEntry>();
private readonly clock: Clock;
public constructor(options: { clock?: Clock } = {}) {
this.clock = options.clock ?? defaultClock;
}
/** Record an opt-out; overwrites the previous entry for `phoneNumber`. */
public add(phoneNumber: string, channel?: string): OptOutEntry {
const entry: OptOutEntry = {
phoneNumber,
optedOutAt: new Date(this.clock()),
...(channel !== undefined ? { channel } : {}),
};
this.entries.set(phoneNumber, entry);
return entry;
}
/** Remove an opt-out entry; returns `true` iff a row was removed. */
public remove(phoneNumber: string): boolean {
return this.entries.delete(phoneNumber);
}
/** Return `true` iff the recipient is opted out. */
public has(phoneNumber: string): boolean {
return this.entries.has(phoneNumber);
}
/** Look up the opt-out entry; returns `undefined` if not opted out. */
public get(phoneNumber: string): OptOutEntry | undefined {
return this.entries.get(phoneNumber);
}
/** Snapshot every opt-out entry. */
public list(): ReadonlyArray<OptOutEntry> {
return [...this.entries.values()];
}
/** Bulk-import entries; existing entries are overwritten by `phoneNumber`. */
public importEntries(entries: ReadonlyArray<OptOutEntry>): void {
for (const entry of entries) {
this.entries.set(entry.phoneNumber, entry);
}
}
/** Drop every entry. */
public clear(): void {
this.entries.clear();
}
/** Total entry count. */
public size(): number {
return this.entries.size;
}
}

View File

@@ -0,0 +1,7 @@
export {
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
recordSmsSent,
refreshSmsMeters,
} from "./metrics.js";

View File

@@ -0,0 +1,98 @@
# `otel/metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel/metrics` is the OpenTelemetry instrumentation layer for
`@suaveplan/sms`. It owns four meter instruments — three
counters and a histogram — that `SmsService` populates on every
send, retry, and failure. Wiring observability at the package
level keeps consumers free from re-defining instrument names and
units, and gives downstream dashboards a stable contract to plot
against.
The module reads the global `MeterProvider` from
`@opentelemetry/api`. Tests that wire an in-memory exporter call
`refreshSmsMeters()` after installing the exporter so subsequent
`record*` calls land on the test provider rather than the
load-time meter.
## Features
- `sms.sent_total{channel, from_me}` counter — successful sends.
- `sms.failed_total{code}` counter — failed sends, tagged by
error code.
- `sms.retry_total{attempt}` counter — retry attempts, tagged
by zero-based attempt index.
- `sms.send_duration_ms{outcome}` histogram — pipeline duration
in milliseconds, outcome ∈ {`ok`, `error`, `aborted`}.
- `refreshSmsMeters()` re-resolves every instrument against the
current `MeterProvider` for test wiring.
## Usage: Basic
```ts
import {
recordSmsSent,
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
} from "@suaveplan/sms/otel";
const start = Date.now();
try {
// ... send ...
recordSmsSent("marketing", false);
recordSmsSendDuration(Date.now() - start, "ok");
} catch (err) {
recordSmsFailed("SMS.SEND_FAILED");
recordSmsSendDuration(Date.now() - start, "error");
}
```
## Usage: Advanced
```ts
import {
MeterProvider,
PeriodicExportingMetricReader,
ConsoleMetricExporter,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { refreshSmsMeters } from "@suaveplan/sms/otel";
const provider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
}),
],
});
metrics.setGlobalMeterProvider(provider);
refreshSmsMeters(); // rebind every instrument against the new provider
```
## API Reference
- `function recordSmsSent(channel: string, fromMe: boolean): void`
- `function recordSmsFailed(code: string): void`
- `function recordSmsRetry(attempt: number): void`
- `function recordSmsSendDuration(durationMs: number, outcome:
"ok" | "error" | "aborted"): void`
- `function refreshSmsMeters(): void`
## Implementation Notes
Instruments are stored at module scope so the hot send path
performs zero allocation per record call. The default no-op
meter provided by `@opentelemetry/api` accepts every call
silently, so packages that never install a `MeterProvider` pay
nothing. Calling `refreshSmsMeters()` re-creates each instrument
against the current global — necessary because instrument
identity is bound to the meter instance, not the API surface.
The attribute keys (`channel`, `from_me`, `code`, `attempt`,
`outcome`) are part of the public observability contract; renames
require a major version bump.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
recordSmsSent,
refreshSmsMeters,
} from "./metrics.js";
describe("OTel SMS metrics", () => {
it("record helpers do not throw against the default no-op meter", {
timeout: 5000,
}, () => {
expect(() => recordSmsSent("default", false)).not.toThrow();
expect(() => recordSmsFailed("SMS.SEND_FAILED")).not.toThrow();
expect(() => recordSmsRetry(0)).not.toThrow();
expect(() => recordSmsSendDuration(12, "ok")).not.toThrow();
expect(() => recordSmsSendDuration(12, "error")).not.toThrow();
expect(() => recordSmsSendDuration(12, "aborted")).not.toThrow();
});
it("refresh re-binds against the global provider", {
timeout: 5000,
}, () => {
// Calling refresh twice exercises the rebind path.
refreshSmsMeters();
refreshSmsMeters();
expect(() => recordSmsSent("default", true)).not.toThrow();
});
});

View File

@@ -0,0 +1,72 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/sms";
const METER_VERSION = "0.1.0";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let sentCounter: Counter = meter.createCounter("sms.sent_total", {
description: "Count of successfully sent SMS messages",
});
let failedCounter: Counter = meter.createCounter("sms.failed_total", {
description: "Count of failed SMS sends by error code",
});
let retryCounter: Counter = meter.createCounter("sms.retry_total", {
description: "Count of retry attempts by attempt index",
});
let sendDurationHistogram: Histogram = meter.createHistogram(
"sms.send_duration_ms",
{
description: "SMS send pipeline 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 refreshSmsMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
sentCounter = meter.createCounter("sms.sent_total", {
description: "Count of successfully sent SMS messages",
});
failedCounter = meter.createCounter("sms.failed_total", {
description: "Count of failed SMS sends by error code",
});
retryCounter = meter.createCounter("sms.retry_total", {
description: "Count of retry attempts by attempt index",
});
sendDurationHistogram = meter.createHistogram("sms.send_duration_ms", {
description: "SMS send pipeline duration in milliseconds",
unit: "ms",
});
}
/** Record a successful SMS send. */
export function recordSmsSent(channel: string, fromMe: boolean): void {
sentCounter.add(1, { channel, from_me: String(fromMe) });
}
/** Record a failed SMS send, tagged by code. */
export function recordSmsFailed(code: string): void {
failedCounter.add(1, { code });
}
/** Record a retry attempt, tagged by zero-based attempt index. */
export function recordSmsRetry(attempt: number): void {
retryCounter.add(1, { attempt: String(attempt) });
}
/** Record the pipeline duration. */
export function recordSmsSendDuration(
durationMs: number,
outcome: "ok" | "error" | "aborted"
): void {
sendDurationHistogram.record(durationMs, { outcome });
}

View File

@@ -0,0 +1 @@
export { RateLimiter } from "./rate-limiter.js";

View File

@@ -0,0 +1,92 @@
# `rate-limiter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`rate-limiter` is the token-bucket throttle that `SmsService`
consults before every outbound send. The limiter is keyed by an
arbitrary string (the service uses `${channel}:${recipient}` so
marketing and transactional channels share no quota) and refills
continuously against an injectable `Clock`, making it
deterministic in tests and fair under bursty load.
The default settings — capacity `60`, refill `1` token per second
— tolerate a 60-message burst followed by a steady stream and
cover most transactional SMS workloads; high-throughput hosts can
raise the bucket capacity or the refill rate via the constructor.
## Features
- Token-bucket algorithm with continuous refill against an
injectable `Clock`.
- Per-key buckets — independent quotas across channels /
recipients.
- `tryConsume(key)` returns a boolean so callers branch on
rejection without exception cost.
- `peek(key)` for observability — returns the current token
count after applying the refill.
- `clear()` to reset every bucket (useful for tests).
- Constructor-time validation of `capacity > 0` and
`refillPerSecond > 0`.
## Usage: Basic
```ts
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
const limiter = new RateLimiter({ capacity: 10, refillPerSecond: 1 });
for (let i = 0; i < 12; i += 1) {
if (!limiter.tryConsume("+15551234567")) {
console.log("rate limited");
break;
}
}
```
## Usage: Advanced
```ts
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
let now = 1_000;
const limiter = new RateLimiter({
capacity: 3,
refillPerSecond: 1,
clock: () => now,
});
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // false — empty
now += 1_000;
limiter.tryConsume("k"); // true again after refill
console.log(limiter.peek("k")); // 0
```
## API Reference
- `class RateLimiter`
- `constructor(options?: RateLimiterOptions)`
- `tryConsume(key: string): boolean`
- `peek(key: string): number`
- `clear(): void`
- `interface RateLimiterOptions`
- `capacity?: number` (default 60)
- `refillPerSecond?: number` (default 1)
- `clock?: Clock` (default `Date.now`)
## Implementation Notes
The refill is fractional — `(elapsedMs / 1000) * refillPerSecond`
— so the limiter does not snap-fill on whole seconds. Tokens are
clamped to `capacity` after refill, so a long pause does not let
the bucket accumulate more than the cap. The `clock` defaults to
`Date.now` and is invoked once per `tryConsume` / `peek` so wall-
clock drift is observable but does not corrupt state. Constructor
validation throws plain `Error` (not `SmsInvalidConfigError`)
because the limiter is a leaf primitive — callers compose Zod
validation higher in the stack via `SmsService`.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimiter } from "./rate-limiter.js";
function fakeClock(): { now: () => number; advance: (ms: number) => void } {
let now = 1000;
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
};
}
describe("RateLimiter", () => {
it("exhausts after capacity", { timeout: 5000 }, () => {
const limiter = new RateLimiter({
capacity: 3,
refillPerSecond: 1,
clock: () => 1000,
});
expect(limiter.tryConsume("k")).toBe(true);
expect(limiter.tryConsume("k")).toBe(true);
expect(limiter.tryConsume("k")).toBe(true);
expect(limiter.tryConsume("k")).toBe(false);
});
it("refills on clock advance", { timeout: 5000 }, () => {
const clock = fakeClock();
const limiter = new RateLimiter({
capacity: 1,
refillPerSecond: 1,
clock: clock.now,
});
expect(limiter.tryConsume("k")).toBe(true);
expect(limiter.tryConsume("k")).toBe(false);
clock.advance(1000);
expect(limiter.tryConsume("k")).toBe(true);
});
it("buckets are independent per key", { timeout: 5000 }, () => {
const limiter = new RateLimiter({
capacity: 1,
refillPerSecond: 1,
clock: () => 1000,
});
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("b")).toBe(true);
expect(limiter.tryConsume("a")).toBe(false);
});
it("peek shows current token count after refill", {
timeout: 5000,
}, () => {
const clock = fakeClock();
const limiter = new RateLimiter({
capacity: 4,
refillPerSecond: 2,
clock: clock.now,
});
limiter.tryConsume("k");
limiter.tryConsume("k");
expect(limiter.peek("k")).toBe(2);
clock.advance(500);
expect(limiter.peek("k")).toBe(3);
});
it("clear resets every bucket", { timeout: 5000 }, () => {
const limiter = new RateLimiter({
capacity: 1,
refillPerSecond: 1,
clock: () => 1000,
});
limiter.tryConsume("k");
expect(limiter.tryConsume("k")).toBe(false);
limiter.clear();
expect(limiter.tryConsume("k")).toBe(true);
});
it("rejects non-positive capacity", { timeout: 5000 }, () => {
expect(() => new RateLimiter({ capacity: 0 })).toThrow();
});
it("rejects non-positive refillPerSecond", { timeout: 5000 }, () => {
expect(() => new RateLimiter({ refillPerSecond: 0 })).toThrow();
});
it("uses default clock when not injected", { timeout: 5000 }, () => {
const limiter = new RateLimiter();
expect(limiter.tryConsume("k")).toBe(true);
});
it("caps refill at capacity", { timeout: 5000 }, () => {
const clock = fakeClock();
const limiter = new RateLimiter({
capacity: 2,
refillPerSecond: 10,
clock: clock.now,
});
clock.advance(100_000);
expect(limiter.peek("k")).toBe(2);
});
});

View File

@@ -0,0 +1,76 @@
import type { Clock } from "@suaveplan/types";
import type { RateLimiterOptions } from "../types/sms.types.js";
const defaultClock: Clock = () => Date.now();
interface Bucket {
tokens: number;
lastRefillMs: number;
}
/**
* Token-bucket rate limiter keyed by an arbitrary string. Tokens refill
* continuously against an injectable monotonic clock so the limiter is
* deterministic under test. The default configuration (`capacity = 60`,
* `refillPerSecond = 1`) tolerates a 60-message burst followed by a
* 1-per-second steady stream — sane defaults for transactional SMS.
*/
export class RateLimiter {
private readonly capacity: number;
private readonly refillPerSecond: number;
private readonly clock: Clock;
private readonly buckets = new Map<string, Bucket>();
public constructor(options: RateLimiterOptions = {}) {
this.capacity = options.capacity ?? 60;
this.refillPerSecond = options.refillPerSecond ?? 1;
this.clock = options.clock ?? defaultClock;
if (this.capacity <= 0) {
throw new Error("RateLimiter.capacity must be > 0");
}
if (this.refillPerSecond <= 0) {
throw new Error("RateLimiter.refillPerSecond must be > 0");
}
}
/** Attempt to consume one token for `key`; returns `true` on success. */
public tryConsume(key: string): boolean {
const bucket = this.getBucket(key);
this.refill(bucket);
if (bucket.tokens < 1) {
return false;
}
bucket.tokens -= 1;
return true;
}
/** Inspect the current token count for `key` (after refill). */
public peek(key: string): number {
const bucket = this.getBucket(key);
this.refill(bucket);
return bucket.tokens;
}
/** Reset every bucket. */
public clear(): void {
this.buckets.clear();
}
private getBucket(key: string): Bucket {
let bucket = this.buckets.get(key);
if (bucket === undefined) {
bucket = { tokens: this.capacity, lastRefillMs: this.clock() };
this.buckets.set(key, bucket);
}
return bucket;
}
private refill(bucket: Bucket): void {
const nowMs = this.clock();
const elapsedMs = Math.max(0, nowMs - bucket.lastRefillMs);
if (elapsedMs === 0) return;
const refill = (elapsedMs / 1000) * this.refillPerSecond;
bucket.tokens = Math.min(this.capacity, bucket.tokens + refill);
bucket.lastRefillMs = nowMs;
}
}

View File

@@ -0,0 +1 @@
export { type RetryHook, RetryPolicy } from "./retry.js";

View File

@@ -0,0 +1,93 @@
# `retry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`retry` is the exponential-backoff retry policy used by
`SmsService.send` to absorb transient provider failures. The
policy retries a thrown attempt up to `maxAttempts` times with
exponential backoff (`initialDelayMs * 2^attempt`, capped at
`maxDelayMs`) and full jitter, awaits each backoff against an
injectable `Timers` seam so tests can drive the policy without
real wall-clock waits, and honours an `AbortSignal` so cancelled
operations don't sit through a multi-second backoff.
## Features
- Exponential backoff with full-jitter delay computation.
- Injectable `TimersLike` seam — defaults to a
`globalThis.setTimeout` adapter; tests inject a controlled
timer.
- Injectable `random` source — defaults to `Math.random`; tests
pin it to a fixed value for deterministic jitter.
- AbortSignal-aware — a pre-aborted signal halts before the
first attempt; an abort that lands during a backoff cancels
the timer and rejects with `SmsAbortedError`.
- Per-attempt `onRetry` hook receives a `SmsRetryEvent` so
callers can record per-attempt OTel counters / bus events.
## Usage: Basic
```ts
import { RetryPolicy } from "@suaveplan/sms/retry";
const policy = new RetryPolicy({ maxAttempts: 3 });
const value = await policy.execute(async () => callRemoteService());
```
## Usage: Advanced
```ts
import { RetryPolicy } from "@suaveplan/sms/retry";
const ctrl = new AbortController();
const policy = new RetryPolicy({
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 2_000,
});
const result = await policy.execute(
async (attempt) => {
if (attempt < 2) throw new Error("flaky");
return "ok";
},
{
signal: ctrl.signal,
onRetry: (event) => {
console.log("retry", event.attempt, event.error.message);
},
to: "+15551234567",
}
);
```
## API Reference
- `class RetryPolicy`
- `constructor(options?: RetryPolicyOptions)`
- `execute<T>(fn: (attempt: number) => Promise<T>, options?: {
signal?: AbortSignal; onRetry?: RetryHook; to?: string;
}): Promise<T>`
- `type RetryHook = (event: SmsRetryEvent) => void`.
- `interface RetryPolicyOptions`
- `maxAttempts?: number` (default 3)
- `initialDelayMs?: number` (default 50)
- `maxDelayMs?: number` (default 5000)
- `timers?: TimersLike`
- `random?: () => number`
## Implementation Notes
The backoff uses full jitter (`floor(random() * capped)`) which
empirically produces lower contention than equal jitter for SMS
provider rate-limit scenarios. The `onRetry` hook receives the
ZERO-based attempt index for the attempt that just failed, so
the first retry fires `attempt = 0`. The delay loop guards
against abort via an `AbortSignal.addEventListener("abort", …,
{ once: true })` listener that clears the pending timer and
rejects synchronously, ensuring an aborted retry never wakes up
to perform the next attempt.

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { SmsAbortedError } from "../error-codes/error-codes.js";
import { RetryPolicy } from "./retry.js";
function instantTimers(): TimersLike {
return {
setTimer(cb: () => void): number {
queueMicrotask(cb);
return 0;
},
clearTimer(): void {
// no-op for instant scheduler
},
};
}
function controlledTimers(): {
timers: TimersLike;
fire: () => void;
pending: () => boolean;
} {
let pending: { cb: () => void; handle: number } | undefined;
let nextHandle = 1;
return {
timers: {
setTimer(cb: () => void): number {
const handle = nextHandle++;
pending = { cb, handle };
return handle;
},
clearTimer(handle: number): void {
if (pending?.handle === handle) pending = undefined;
},
},
fire: (): void => {
const p = pending;
pending = undefined;
p?.cb();
},
pending: (): boolean => pending !== undefined,
};
}
describe("RetryPolicy", () => {
it("returns immediately on first success", { timeout: 5000 }, async () => {
const policy = new RetryPolicy({
maxAttempts: 3,
timers: instantTimers(),
random: () => 0,
});
const out = await policy.execute(async () => "ok");
expect(out).toBe("ok");
});
it("succeeds on second attempt", { timeout: 5000 }, async () => {
let attempt = 0;
const onRetry = (): void => {
// counted via attempts
};
const policy = new RetryPolicy({
maxAttempts: 3,
timers: instantTimers(),
random: () => 0,
});
const out = await policy.execute(
async () => {
attempt += 1;
if (attempt === 1) throw new Error("first");
return "ok";
},
{ onRetry }
);
expect(out).toBe("ok");
expect(attempt).toBe(2);
});
it("exhausts attempts", { timeout: 5000 }, async () => {
const policy = new RetryPolicy({
maxAttempts: 3,
timers: instantTimers(),
random: () => 0,
});
let attempt = 0;
await expect(
policy.execute(async () => {
attempt += 1;
throw new Error(`fail ${attempt}`);
})
).rejects.toThrow("fail 3");
expect(attempt).toBe(3);
});
it("wraps non-Error throws", { timeout: 5000 }, async () => {
const policy = new RetryPolicy({
maxAttempts: 1,
timers: instantTimers(),
random: () => 0,
});
const bad: unknown = "string";
await expect(
policy.execute(async () => {
throw bad;
})
).rejects.toThrow("string");
});
it("abort halts retries", { timeout: 5000 }, async () => {
const ctrl = controlledTimers();
const policy = new RetryPolicy({
maxAttempts: 5,
timers: ctrl.timers,
random: () => 0,
});
const abortController = new AbortController();
const p = policy
.execute(
async () => {
throw new Error("nope");
},
{ signal: abortController.signal }
)
.catch((e: unknown) => e);
// wait microtask cycle so the first attempt rejected and policy is in delay
await Promise.resolve();
await Promise.resolve();
expect(ctrl.pending()).toBe(true);
abortController.abort(new Error("user-cancel"));
const result = await p;
expect(result).toBeInstanceOf(SmsAbortedError);
});
it("pre-aborted signal halts immediately", { timeout: 5000 }, async () => {
const policy = new RetryPolicy({
maxAttempts: 3,
timers: instantTimers(),
random: () => 0,
});
const ctrl = new AbortController();
ctrl.abort(new Error("cancel"));
await expect(
policy.execute(async () => "ok", { signal: ctrl.signal })
).rejects.toBeInstanceOf(SmsAbortedError);
});
it("uses defaults when no options provided", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy();
const out = await policy.execute(async () => 7);
expect(out).toBe(7);
});
it("uses default random source when none injected", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy({
maxAttempts: 2,
initialDelayMs: 1,
maxDelayMs: 1,
timers: instantTimers(),
});
let attempt = 0;
await expect(
policy.execute(async () => {
attempt += 1;
throw new Error("flaky");
})
).rejects.toThrow("flaky");
expect(attempt).toBe(2);
});
it("invokes onRetry between attempts", { timeout: 5000 }, async () => {
const events: number[] = [];
const policy = new RetryPolicy({
maxAttempts: 3,
timers: instantTimers(),
random: () => 0,
});
let attempt = 0;
await policy.execute(
async () => {
attempt += 1;
if (attempt < 3) throw new Error("retry me");
return "ok";
},
{
onRetry: (e) => events.push(e.attempt),
to: "+15551234567",
}
);
expect(events).toEqual([0, 1]);
});
it("rejects maxAttempts < 1", { timeout: 5000 }, () => {
expect(() => new RetryPolicy({ maxAttempts: 0 })).toThrow();
});
it("caps delay at maxDelayMs", { timeout: 5000 }, async () => {
const policy = new RetryPolicy({
maxAttempts: 4,
initialDelayMs: 1000,
maxDelayMs: 100,
timers: instantTimers(),
random: () => 1,
});
let attempt = 0;
await expect(
policy.execute(async () => {
attempt += 1;
throw new Error("nope");
})
).rejects.toThrow("nope");
expect(attempt).toBe(4);
});
it("handles signal abort with non-Error reason", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
const policy = new RetryPolicy({
maxAttempts: 1,
timers: instantTimers(),
random: () => 0,
});
await expect(
policy.execute(async () => "x", { signal: ctrl.signal })
).rejects.toBeInstanceOf(SmsAbortedError);
});
it("uses platform timers when none injected", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy({
maxAttempts: 2,
initialDelayMs: 1,
maxDelayMs: 2,
random: () => 0,
});
let attempt = 0;
await expect(
policy.execute(async () => {
attempt += 1;
throw new Error("oops");
})
).rejects.toThrow("oops");
expect(attempt).toBe(2);
});
it("platform timers abort path clears the real timer", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy({
maxAttempts: 5,
initialDelayMs: 200,
maxDelayMs: 200,
random: () => 1,
});
const ctrl = new AbortController();
const p = policy
.execute(
async () => {
throw new Error("fail");
},
{ signal: ctrl.signal }
)
.catch((e: unknown) => e);
// Let the first attempt fail and the delay begin
await new Promise((resolve) => setTimeout(resolve, 10));
ctrl.abort();
const result = await p;
expect(result).toBeInstanceOf(SmsAbortedError);
});
it("onRetry wraps non-Error rejections", { timeout: 5000 }, async () => {
const events: Error[] = [];
const policy = new RetryPolicy({
maxAttempts: 2,
timers: instantTimers(),
random: () => 0,
});
await expect(
policy.execute(
async () => {
const bad: unknown = "non-error";
throw bad;
},
{
onRetry: (e) => events.push(e.error),
}
)
).rejects.toThrow("non-error");
expect(events[0]).toBeInstanceOf(Error);
expect(events[0]?.message).toBe("non-error");
});
it("abort with undefined reason uses default label", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy({
maxAttempts: 1,
random: () => 0,
});
const ctrl = new AbortController();
ctrl.abort();
const result = await policy
.execute(async () => 1, { signal: ctrl.signal })
.catch((e: unknown) => e);
expect(result).toBeInstanceOf(SmsAbortedError);
});
it("non-Error abort reason stringifies", {
timeout: 5000,
}, async () => {
const policy = new RetryPolicy({
maxAttempts: 1,
random: () => 0,
});
const ctrl = new AbortController();
ctrl.abort("custom-string");
const result = await policy
.execute(async () => 1, { signal: ctrl.signal })
.catch((e: unknown) => e);
expect(result).toBeInstanceOf(SmsAbortedError);
expect((result as SmsAbortedError).context?.["reason"]).toContain(
"custom-string"
);
});
});

View File

@@ -0,0 +1,125 @@
import type { TimersLike } from "@suaveplan/types";
import { SmsAbortedError } from "../error-codes/error-codes.js";
import type { RetryPolicyOptions, SmsRetryEvent } from "../types/sms.types.js";
/** Platform timers fall back to globalThis. */
const platformTimers: TimersLike = {
setTimer(callback: () => void, delayMs: number): number {
return globalThis.setTimeout(callback, delayMs) as unknown as number;
},
clearTimer(handle: number): void {
globalThis.clearTimeout(
handle as unknown as ReturnType<typeof setTimeout>
);
},
};
/** Default jitter random source — `Math.random`. */
const defaultRandom = (): number => Math.random();
/** Hook invoked between attempts. */
export type RetryHook = (event: SmsRetryEvent) => void;
/**
* Exponential-backoff retry policy with full jitter. Each attempt is
* awaited against the injectable `Timers.setTimer` seam so tests can drive
* the policy without real wall-clock waits. The callback receives a
* zero-based attempt counter so callers can record per-attempt telemetry.
*
* The policy is abort-aware: a triggered `signal.aborted` interrupts the
* pending delay AND prevents the next attempt, throwing `SmsAbortedError`.
*/
export class RetryPolicy {
private readonly maxAttempts: number;
private readonly initialDelayMs: number;
private readonly maxDelayMs: number;
private readonly timers: TimersLike;
private readonly random: () => number;
public constructor(options: RetryPolicyOptions = {}) {
this.maxAttempts = options.maxAttempts ?? 3;
this.initialDelayMs = options.initialDelayMs ?? 50;
this.maxDelayMs = options.maxDelayMs ?? 5_000;
this.timers = options.timers ?? platformTimers;
this.random = options.random ?? defaultRandom;
if (this.maxAttempts < 1) {
throw new Error("RetryPolicy.maxAttempts must be ≥ 1");
}
}
/** Execute `fn`, retrying on rejection up to `maxAttempts` times. */
public async execute<T>(
fn: (attempt: number) => Promise<T>,
options: { signal?: AbortSignal; onRetry?: RetryHook; to?: string } = {}
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) {
this.throwIfAborted(options.signal);
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt === this.maxAttempts - 1) break;
if (options.onRetry !== undefined) {
options.onRetry({
to: options.to ?? "",
attempt,
error:
error instanceof Error
? error
: new Error(String(error)),
});
}
await this.delay(this.computeDelay(attempt), options.signal);
}
}
if (lastError instanceof Error) throw lastError;
throw new Error(String(lastError));
}
private computeDelay(attempt: number): number {
const base = this.initialDelayMs * 2 ** attempt;
const capped = Math.min(this.maxDelayMs, base);
// Full jitter — random in [0, capped].
return Math.floor(this.random() * capped);
}
private throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new SmsAbortedError({
context: { reason: stringifyReason(signal.reason) },
});
}
}
private async delay(ms: number, signal?: AbortSignal): Promise<void> {
await new Promise<void>((resolve, reject) => {
const handle = this.timers.setTimer(() => {
cleanup();
resolve();
}, ms);
const cleanup = (): void => {
if (signal !== undefined) {
signal.removeEventListener("abort", onAbort);
}
};
const onAbort = (): void => {
this.timers.clearTimer(handle);
cleanup();
reject(
new SmsAbortedError({
context: { reason: stringifyReason(signal?.reason) },
})
);
};
if (signal !== undefined) {
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
}
function stringifyReason(reason: unknown): string {
if (reason instanceof Error) return reason.message;
return String(reason);
}

View File

@@ -0,0 +1,86 @@
# `routes/problem-json`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`routes/problem-json` is the RFC 7807 `application/problem+json`
response helper for hosts wiring `@suaveplan/sms` errors onto a
Hono-shaped router. It serialises any `SuaveplanError` (current
or legacy), or unknown throwable, into a problem-details
document on the supplied context-like surface and returns the
resulting `Response` so callers can `return` it directly from a
Hono handler.
The helper preserves the upstream registered `httpStatus` from
`SMS_ERROR_METADATA` when the error's `code` is one of the six
legacy keys; for `SMS.*` codes the helper maps to
a sensible status (`SMS.RATE_LIMITED` → 429, `SMS.INVALID_NUMBER`
→ 400, `SMS.ABORTED` → 499, …); for unknown errors the helper
falls back to 500.
## Features
- `PROBLEM_JSON_CONTENT_TYPE` — the RFC 7807 media type.
- `respondProblemJson(c, err)` — context-agnostic helper that
takes any Hono-like surface (anything with `header(name,
value)` and `body(data, status)`).
- Maps `SuaveplanError.code` → HTTP status via the legacy
table, then the `SMS.*` switch, then a 500 fallback.
- Propagates `SuaveplanError.context` into the problem-details
document so callers can surface Zod issues, opt-out targets,
etc.
## Usage: Basic
```ts
import { Hono } from "hono";
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
const app = new Hono();
app.post("/sms", async (c) => {
try {
// ... service.send ...
return c.json({ ok: true });
} catch (err) {
return respondProblemJson(c, err);
}
});
```
## Usage: Advanced
```ts
import { SmsRateLimitedError } from "@suaveplan/sms/error-codes";
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
const res = respondProblemJson(c, new SmsRateLimitedError({
context: { phoneNumber: "+15551234567", key: "marketing:+15551234567" },
}));
console.log(res.status); // 429
const body = await res.json();
console.log(body.context); // { phoneNumber: "...", key: "..." }
```
## API Reference
- `const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`.
- `interface ProblemDetails` — RFC 7807 shape with `title`,
`status`, optional `type`/`detail`/`instance`/`code`, plus an
open index signature.
- `interface HonoLike` — `header(name, value): void`,
`body(data, status): Response`.
- `function respondProblemJson(c: HonoLike, err: unknown): Response`.
## Implementation Notes
The helper is intentionally context-agnostic — it accepts any
object satisfying the minimal `HonoLike` surface so test
harnesses can pass a stub. The status-mapping cascade
prioritises the upstream `SMS_ERROR_METADATA` table so a legacy
caller continues to see the same status codes after migration.
Unknown error throwables fall back to 500 with the error
message as the title, or `"Internal Server Error"` when the
message is empty, preserving the upstream behaviour.

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SmsInvalidNumberError,
SmsRateLimitedError,
SmsScheduleCancelledError,
} from "../error-codes/error-codes.js";
import { SmsError } from "../legacy-errors/legacy-errors.js";
import {
PROBLEM_JSON_CONTENT_TYPE,
respondProblemJson,
} from "./problem-json.js";
function fakeContext(): {
headers: Map<string, string>;
header(name: string, value: string): void;
body(data: string, status: number): Response;
} {
const headers = new Map<string, string>();
return {
headers,
header(name: string, value: string): void {
headers.set(name.toLowerCase(), value);
},
body(data: string, status: number): Response {
return new Response(data, { status });
},
};
}
describe("respondProblemJson", () => {
it("sets the problem+json content type", { timeout: 5000 }, () => {
const c = fakeContext();
respondProblemJson(c, new SmsInvalidNumberError());
expect(c.headers.get("content-type")).toBe(PROBLEM_JSON_CONTENT_TYPE);
});
it("maps SMS.RATE_LIMITED to 429", { timeout: 5000 }, async () => {
const c = fakeContext();
const res = respondProblemJson(c, new SmsRateLimitedError());
expect(res.status).toBe(429);
const body = (await res.json()) as Record<string, unknown>;
expect(body["status"]).toBe(429);
expect(body["code"]).toBe("SMS.RATE_LIMITED");
});
it("maps SMS.INVALID_NUMBER to 400", { timeout: 5000 }, async () => {
const c = fakeContext();
const res = respondProblemJson(c, new SmsInvalidNumberError());
expect(res.status).toBe(400);
});
it("maps SMS.SCHEDULE_CANCELLED to 409", { timeout: 5000 }, async () => {
const c = fakeContext();
const res = respondProblemJson(c, new SmsScheduleCancelledError());
expect(res.status).toBe(409);
});
it("registered code preserves httpStatus", { timeout: 5000 }, async () => {
const c = fakeContext();
const err = new SmsError({
code: "SMS_RATE_LIMITED" as never,
message: "limit",
});
const res = respondProblemJson(c, err);
const body = (await res.json()) as Record<string, unknown>;
expect(body["status"]).toBe(429);
});
it("unknown error falls back to 500", { timeout: 5000 }, async () => {
const c = fakeContext();
const res = respondProblemJson(c, new Error("boom"));
expect(res.status).toBe(500);
const body = (await res.json()) as Record<string, unknown>;
expect(body["title"]).toBe("boom");
});
it("non-Error throwable falls back to 500", {
timeout: 5000,
}, async () => {
const c = fakeContext();
const res = respondProblemJson(c, "string-error");
expect(res.status).toBe(500);
});
it("Error without message uses default title", {
timeout: 5000,
}, async () => {
const c = fakeContext();
const err = new Error("");
const res = respondProblemJson(c, err);
const body = (await res.json()) as Record<string, unknown>;
expect(body["title"]).toBe("Internal Server Error");
});
it("genesis code without legacy mapping uses fall-back status", {
timeout: 5000,
}, async () => {
const c = fakeContext();
// Create an arbitrary unknown-prefixed code to hit the default branch.
class WeirdError extends Error {
public readonly code = "OTHER.WEIRD";
public constructor() {
super("weird");
}
}
const res = respondProblemJson(c, new WeirdError());
expect(res.status).toBe(500);
});
it("includes context on SuaveplanError response", {
timeout: 5000,
}, async () => {
const c = fakeContext();
const err = new SmsInvalidNumberError({
context: { phoneNumber: "bad" },
});
const res = respondProblemJson(c, err);
const body = (await res.json()) as Record<string, unknown>;
expect(body["context"]).toEqual({ phoneNumber: "bad" });
});
it("covers every SMS.* code branch", { timeout: 5000 }, async () => {
const mod = await import("../error-codes/error-codes.js");
const errs = [
new mod.SmsInvalidConfigError(),
new mod.SmsProviderNotConfiguredError(),
new mod.SmsConfigMissingError(),
new mod.SmsForbiddenError(),
new mod.SmsReceiptNotFoundError(),
new mod.SmsTemplateNotFoundError(),
new mod.SmsRecipientOptedOutError(),
new mod.SmsAbortedError(),
new mod.SmsScheduleCancelledError(),
new mod.SmsProviderError(),
new mod.SmsSendFailedError(),
];
for (const err of errs) {
const c = fakeContext();
respondProblemJson(c, err);
}
// Default branch — unknown SMS.* sub-code via a subclass with overridden code.
class WeirdSmsError extends mod.BaseSmsError {
public constructor() {
super("SMS.WEIRD" as never, "weird", {});
}
}
respondProblemJson(fakeContext(), new WeirdSmsError());
// Non-SMS-prefix code hits the final `return 500` branch.
class OtherCoded extends mod.BaseSmsError {
public constructor() {
super("OTHER.STUFF" as never, "stuff", {});
}
}
const c = fakeContext();
const res = respondProblemJson(c, new OtherCoded());
expect(res.status).toBe(500);
});
});

View File

@@ -0,0 +1,101 @@
import { SuaveplanError } from "@suaveplan/error";
import {
SMS_ERROR_METADATA,
type SmsRegisteredErrorCode,
} from "../legacy-metadata/legacy-metadata.js";
/** Standard problem+json content-type per RFC 7807. */
export const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json";
/** RFC 7807 problem-details document shape. */
export interface ProblemDetails {
type?: string;
title: string;
status: number;
detail?: string;
instance?: string;
code?: string;
[key: string]: unknown;
}
/** Minimal structural Hono context surface we rely on. */
export interface HonoLike {
header(name: string, value: string): void;
body(data: string, status: number): Response;
}
/**
* Render `err` as RFC 7807 problem+json on the supplied Hono-like context.
* Returns the Response so callers can `return` it directly. SMS errors
* whose `code` exists in `SMS_ERROR_METADATA` map their registered
* `httpStatus`; unknown throwables fall back to 500.
*/
export function respondProblemJson(c: HonoLike, err: unknown): Response {
const body = formatProblem(err);
c.header("content-type", PROBLEM_JSON_CONTENT_TYPE);
return c.body(JSON.stringify(body), body.status);
}
function formatProblem(err: unknown): ProblemDetails {
if (err instanceof SuaveplanError) {
const status = httpStatusForCode(err.code);
const details: ProblemDetails = {
title: err.message,
status,
code: err.code,
};
if (err.context !== undefined) {
details["context"] = err.context;
}
return details;
}
if (err instanceof Error) {
return { title: err.message || "Internal Server Error", status: 500 };
}
return { title: "Internal Server Error", status: 500 };
}
function httpStatusForCode(code: string): number {
// Map genesis SMS.* codes onto upstream registered metadata.
const legacyKey = mapToLegacyKey(code);
if (legacyKey !== undefined) {
return SMS_ERROR_METADATA[legacyKey].httpStatus;
}
if (code.startsWith("SMS.")) {
switch (code) {
case "SMS.INVALID_NUMBER":
return 400;
case "SMS.INVALID_CONFIG":
return 400;
case "SMS.RATE_LIMITED":
return 429;
case "SMS.FORBIDDEN":
return 403;
case "SMS.PROVIDER_NOT_CONFIGURED":
case "SMS.CONFIG_MISSING":
return 500;
case "SMS.RECEIPT_NOT_FOUND":
case "SMS.TEMPLATE_NOT_FOUND":
return 404;
case "SMS.RECIPIENT_OPTED_OUT":
return 410;
case "SMS.ABORTED":
return 499;
case "SMS.SCHEDULE_CANCELLED":
return 409;
case "SMS.PROVIDER_ERROR":
case "SMS.SEND_FAILED":
return 502;
default:
return 500;
}
}
return 500;
}
function mapToLegacyKey(code: string): SmsRegisteredErrorCode | undefined {
if (code in SMS_ERROR_METADATA) {
return code as SmsRegisteredErrorCode;
}
return undefined;
}

View File

@@ -0,0 +1 @@
export { MessageScheduler } from "./scheduler.js";

View File

@@ -0,0 +1,97 @@
# `scheduler`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`scheduler` provides `MessageScheduler` — the minimal timer
seam that `SmsService.scheduleAt` uses to fire a delayed
delivery. The class wraps an injectable `TimersLike` so tests
drive it deterministically, computes the delay from an
injectable `Clock`, and returns a `ScheduleHandle` with
`cancel()` so a caller can abort a scheduled message before the
timer fires.
The scheduler is intentionally small: it does not own retry,
rate-limit, opt-out, or template logic — those layers live in
`SmsService` so a scheduled message goes through the full
pipeline when the timer fires.
## Features
- `scheduleAt(date, fn)` — schedules `fn` to run at the target
instant.
- Returns a `ScheduleHandle` with `cancel()` (idempotent) and
the original `sendAt` field for observation.
- Injectable `TimersLike` seam — defaults to a
`globalThis.setTimeout` / `clearTimeout` adapter.
- Injectable `Clock` — defaults to `Date.now`.
- Past dates collapse to a zero-delay timer, matching the
expectation that "send it now (or as soon as possible)".
## Usage: Basic
```ts
import { MessageScheduler } from "@suaveplan/sms/scheduler";
const scheduler = new MessageScheduler();
const handle = scheduler.scheduleAt(
new Date(Date.now() + 60_000),
() => console.log("fire!")
);
handle.cancel(); // call before firing to abort
```
## Usage: Advanced
```ts
import { MessageScheduler } from "@suaveplan/sms/scheduler";
// Controlled timers + clock for deterministic tests.
const pending: Array<{ cb: () => void; delayMs: number }> = [];
const scheduler = new MessageScheduler({
timers: {
setTimer(cb, delayMs) {
pending.push({ cb, delayMs });
return pending.length;
},
clearTimer() {
/* no-op for tests */
},
},
clock: () => 1_000,
});
const handle = scheduler.scheduleAt(new Date(2_000), () => {
/* runs at virtual t=2000 */
});
// Manually fire the queued callback to simulate the timer
pending[0]?.cb();
handle.cancel(); // safe to call after fire
```
## API Reference
- `class MessageScheduler`
- `constructor(options?: MessageSchedulerOptions)`
- `scheduleAt(sendAt: Date, fn: () => void): ScheduleHandle`
- `interface ScheduleHandle`
- `cancel(): void` — idempotent
- `readonly sendAt: Date`
- `interface MessageSchedulerOptions`
- `timers?: TimersLike`
- `clock?: Clock`
## Implementation Notes
The scheduler tracks a `cancelled` flag inside its timer
callback so a cancellation that races the timer (i.e. the
caller calls `cancel` after the timer has already been pulled
but before the callback runs) is still honoured. Double-cancel
is a no-op. The platform adapter casts the `setTimeout` return
to `number` for cross-runtime compatibility — Node returns a
`Timeout` object, browsers/Bun return a number; both shapes are
safe to forward back to `clearTimeout`.

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { MessageScheduler } from "./scheduler.js";
function controlledTimers(): {
timers: TimersLike;
fire: (handle: number) => void;
pending: () => Map<number, () => void>;
delayFor: (handle: number) => number;
} {
const pending = new Map<number, { cb: () => void; delayMs: number }>();
let next = 1;
return {
timers: {
setTimer(cb: () => void, delayMs: number): number {
const handle = next++;
pending.set(handle, { cb, delayMs });
return handle;
},
clearTimer(handle: number): void {
pending.delete(handle);
},
},
fire: (handle: number): void => {
const entry = pending.get(handle);
pending.delete(handle);
entry?.cb();
},
pending: () => {
const out = new Map<number, () => void>();
for (const [h, v] of pending) out.set(h, v.cb);
return out;
},
delayFor: (handle: number): number =>
pending.get(handle)?.delayMs ?? -1,
};
}
describe("MessageScheduler", () => {
it("fires at the target time", { timeout: 5000 }, () => {
const ctrl = controlledTimers();
const scheduler = new MessageScheduler({
timers: ctrl.timers,
clock: () => 1000,
});
let fired = false;
const handle = scheduler.scheduleAt(new Date(1500), () => {
fired = true;
});
expect(ctrl.delayFor(1)).toBe(500);
expect(fired).toBe(false);
ctrl.fire(1);
expect(fired).toBe(true);
expect(handle.sendAt.getTime()).toBe(1500);
});
it("cancel prevents firing", { timeout: 5000 }, () => {
const ctrl = controlledTimers();
const scheduler = new MessageScheduler({
timers: ctrl.timers,
clock: () => 1000,
});
let fired = false;
const handle = scheduler.scheduleAt(new Date(2000), () => {
fired = true;
});
handle.cancel();
expect(ctrl.pending().size).toBe(0);
// double-cancel is a no-op
handle.cancel();
expect(fired).toBe(false);
});
it("uses zero delay for past dates", { timeout: 5000 }, () => {
const ctrl = controlledTimers();
const scheduler = new MessageScheduler({
timers: ctrl.timers,
clock: () => 5000,
});
scheduler.scheduleAt(new Date(1000), () => undefined);
expect(ctrl.delayFor(1)).toBe(0);
});
it("uses platform timers and clock by default", {
timeout: 5000,
}, async () => {
const scheduler = new MessageScheduler();
let fired = false;
const handle = scheduler.scheduleAt(new Date(Date.now() + 5), () => {
fired = true;
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fired).toBe(true);
expect(handle.sendAt).toBeInstanceOf(Date);
});
it("cancel-after-schedule short-circuits inside the callback", {
timeout: 5000,
}, () => {
// Timers seam that ignores clearTimer, so a cancelled schedule
// still leaves the pending callback in the queue. We then fire
// manually to exercise the `if (cancelled) return` branch.
const pending: Array<() => void> = [];
const timers: TimersLike = {
setTimer(cb: () => void): number {
pending.push(cb);
return pending.length;
},
clearTimer(): void {
// intentionally a no-op to exercise the race-window guard
},
};
const scheduler = new MessageScheduler({
timers,
clock: () => 1000,
});
let fired = 0;
const handle = scheduler.scheduleAt(new Date(2000), () => {
fired += 1;
});
handle.cancel();
// Fire the still-queued callback
pending[0]?.();
expect(fired).toBe(0);
});
it("cancellation after fire is a no-op (timer already cleared)", {
timeout: 5000,
}, () => {
const ctrl = controlledTimers();
const scheduler = new MessageScheduler({
timers: ctrl.timers,
clock: () => 1000,
});
let fired = 0;
const handle = scheduler.scheduleAt(new Date(1500), () => {
fired += 1;
});
ctrl.fire(1);
handle.cancel();
expect(fired).toBe(1);
});
});

View File

@@ -0,0 +1,48 @@
import type { Clock, TimersLike } from "@suaveplan/types";
import type {
MessageSchedulerOptions,
ScheduleHandle,
} from "../types/sms.types.js";
const platformTimers: TimersLike = {
setTimer(callback: () => void, delayMs: number): number {
return globalThis.setTimeout(callback, delayMs) as unknown as number;
},
clearTimer(handle: number): void {
globalThis.clearTimeout(
handle as unknown as ReturnType<typeof setTimeout>
);
},
};
const defaultClock: Clock = () => Date.now();
/** Schedule a callback to fire at a specific instant. */
export class MessageScheduler {
private readonly timers: TimersLike;
private readonly clock: Clock;
public constructor(options: MessageSchedulerOptions = {}) {
this.timers = options.timers ?? platformTimers;
this.clock = options.clock ?? defaultClock;
}
/** Schedule `fn` to fire at `sendAt`. Returns a cancel handle. */
public scheduleAt(sendAt: Date, fn: () => void): ScheduleHandle {
const nowMs = this.clock();
const delayMs = Math.max(0, sendAt.getTime() - nowMs);
let cancelled = false;
const timerHandle = this.timers.setTimer(() => {
if (cancelled) return;
fn();
}, delayMs);
return {
sendAt,
cancel: () => {
if (cancelled) return;
cancelled = true;
this.timers.clearTimer(timerHandle);
},
};
}
}

View File

@@ -0,0 +1,9 @@
export {
parseSmsDeliveryReport,
parseSmsMessage,
parseSmsTemplate,
SmsDeliveryReportSchema,
SmsDeliveryStatusSchema,
SmsMessageSchema,
SmsTemplateSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,101 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas` is the Zod boundary for every public input that
`@suaveplan/sms` exposes. The schemas mirror the public type
contracts (`SmsMessage`, `SmsTemplate`, `SmsDeliveryReport`,
`SmsDeliveryStatus`) verbatim, and the `parseSmsMessage` /
`parseSmsTemplate` / `parseSmsDeliveryReport` parsers throw a
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with the Zod
issues attached as `context.issues` on rejection.
The schemas use strict `.object()` shapes so unknown keys are
rejected — callers cannot accidentally smuggle private fields
through the public API.
## Features
- `SmsMessageSchema` — strict shape with E.164 `to`, non-empty
`body`, and optional `from`, `templateId`, `templateVars`,
`metadata`, `channel`.
- `SmsTemplateSchema` — strict shape with non-empty `id`,
non-empty `body`, `variables` array, optional `defaults` map.
- `SmsDeliveryStatusSchema` — union of the five status literals.
- `SmsDeliveryReportSchema` — strict shape for receipts.
- `parseSmsMessage`, `parseSmsTemplate`,
`parseSmsDeliveryReport` — throw `SmsInvalidConfigError` on
rejection with the Zod issues attached.
- `stripUndefined` helper strips undefined keys before returning
so the result complies with the
`exactOptionalPropertyTypes` contract.
## Usage: Basic
```ts
import { parseSmsMessage } from "@suaveplan/sms/schemas";
const msg = parseSmsMessage({
to: "+15551234567",
body: "Hello",
});
```
## Usage: Advanced
```ts
import {
SmsMessageSchema,
parseSmsTemplate,
} from "@suaveplan/sms/schemas";
import { SmsInvalidConfigError } from "@suaveplan/sms/error-codes";
try {
const template = parseSmsTemplate({
id: "welcome",
body: "Hi {{name}}",
variables: ["name"],
});
console.log(template.id);
} catch (err) {
if (err instanceof SmsInvalidConfigError) {
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
Record<string, unknown>
>) {
console.error(issue);
}
}
}
// Or use the raw schema for partial validation pipelines:
SmsMessageSchema.safeParse(input);
```
## API Reference
- `SmsMessageSchema: ZodObject` — mirrors `SmsMessage`.
- `SmsTemplateSchema: ZodObject` — mirrors `SmsTemplate`.
- `SmsDeliveryStatusSchema: ZodUnion` — five string literals.
- `SmsDeliveryReportSchema: ZodObject` — mirrors
`SmsDeliveryReport`.
- `parseSmsMessage(input: unknown): SmsMessage` — throws
`SmsInvalidConfigError`.
- `parseSmsTemplate(input: unknown): SmsTemplate` — throws
`SmsInvalidConfigError`.
- `parseSmsDeliveryReport(input: unknown): SmsDeliveryReport` —
throws `SmsInvalidConfigError`.
## Implementation Notes
The default-export form `import z from "zod"` is the only
Bun-safe form (per the workspace's `feedback_zod_import`
note); the named import variant breaks under Bun's module
loader. Each parser returns the result of `stripUndefined` so
the value matches the public type shape (which uses
`exactOptionalPropertyTypes`). Zod's `.strict()` rejects unknown
keys, so consumers cannot pass private fields through the public
boundary.

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SmsInvalidConfigError } from "../error-codes/error-codes.js";
import {
parseSmsDeliveryReport,
parseSmsMessage,
parseSmsTemplate,
SmsDeliveryReportSchema,
SmsDeliveryStatusSchema,
SmsMessageSchema,
SmsTemplateSchema,
} from "./schemas.js";
describe("SmsMessageSchema", () => {
it("accepts a minimal valid message", { timeout: 5000 }, () => {
const out = parseSmsMessage({ to: "+15551234567", body: "hi" });
expect(out.to).toBe("+15551234567");
expect(out.body).toBe("hi");
});
it("rejects empty body", { timeout: 5000 }, () => {
expect(() => parseSmsMessage({ to: "+15551234567", body: "" })).toThrow(
SmsInvalidConfigError
);
});
it("rejects non-E.164 to", { timeout: 5000 }, () => {
try {
parseSmsMessage({ to: "bad", body: "hi" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SmsInvalidConfigError);
const issues = (err as SmsInvalidConfigError).context?.[
"issues"
] as ReadonlyArray<Record<string, unknown>>;
expect(issues.length).toBeGreaterThan(0);
}
});
it("strips undefined optional fields", { timeout: 5000 }, () => {
const out = parseSmsMessage({
to: "+15551234567",
body: "hi",
templateId: undefined,
});
expect("templateId" in out).toBe(false);
});
it("keeps declared optional fields", { timeout: 5000 }, () => {
const out = parseSmsMessage({
to: "+15551234567",
body: "hi",
channel: "marketing",
templateVars: { name: "Alice" },
metadata: { k: 1 },
});
expect(out.channel).toBe("marketing");
expect(out.templateVars).toEqual({ name: "Alice" });
expect(out.metadata).toEqual({ k: 1 });
});
it("exposes the schema", { timeout: 5000 }, () => {
expect(
SmsMessageSchema.safeParse({ to: "+15551234567", body: "hi" })
.success
).toBe(true);
});
});
describe("SmsTemplateSchema", () => {
it("accepts a valid template", { timeout: 5000 }, () => {
const out = parseSmsTemplate({
id: "x",
body: "Hi {{name}}",
variables: ["name"],
});
expect(out.id).toBe("x");
});
it("rejects empty id", { timeout: 5000 }, () => {
expect(() =>
parseSmsTemplate({ id: "", body: "x", variables: [] })
).toThrow(SmsInvalidConfigError);
});
it("strips undefined optional fields", { timeout: 5000 }, () => {
const out = parseSmsTemplate({
id: "x",
body: "Hi",
variables: [],
defaults: undefined,
});
expect("defaults" in out).toBe(false);
});
it("exposes the schema", { timeout: 5000 }, () => {
expect(
SmsTemplateSchema.safeParse({
id: "x",
body: "y",
variables: [],
}).success
).toBe(true);
});
});
describe("SmsDeliveryStatusSchema", () => {
it("accepts every status literal", { timeout: 5000 }, () => {
for (const s of [
"queued",
"sent",
"delivered",
"failed",
"undelivered",
]) {
expect(SmsDeliveryStatusSchema.safeParse(s).success).toBe(true);
}
});
it("rejects unknown statuses", { timeout: 5000 }, () => {
expect(SmsDeliveryStatusSchema.safeParse("pending").success).toBe(
false
);
});
});
describe("SmsDeliveryReportSchema", () => {
it("accepts a valid report", { timeout: 5000 }, () => {
const out = parseSmsDeliveryReport({
messageId: "m1",
to: "+15551234567",
status: "sent",
timestamp: new Date(),
});
expect(out.messageId).toBe("m1");
});
it("rejects empty messageId", { timeout: 5000 }, () => {
expect(() =>
parseSmsDeliveryReport({
messageId: "",
to: "+15551234567",
status: "sent",
timestamp: new Date(),
})
).toThrow(SmsInvalidConfigError);
});
it("strips undefined optional fields", { timeout: 5000 }, () => {
const out = parseSmsDeliveryReport({
messageId: "m1",
to: "+15551234567",
status: "failed",
timestamp: new Date(),
errorCode: undefined,
errorMessage: undefined,
});
expect("errorCode" in out).toBe(false);
expect("errorMessage" in out).toBe(false);
});
it("keeps declared optional error fields", { timeout: 5000 }, () => {
const out = parseSmsDeliveryReport({
messageId: "m1",
to: "+15551234567",
status: "failed",
timestamp: new Date(),
errorCode: "E_PROV",
errorMessage: "boom",
});
expect(out.errorCode).toBe("E_PROV");
expect(out.errorMessage).toBe("boom");
});
it("exposes the schema", { timeout: 5000 }, () => {
expect(
SmsDeliveryReportSchema.safeParse({
messageId: "m1",
to: "+15551234567",
status: "sent",
timestamp: new Date(),
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,102 @@
import z from "zod";
import { SmsInvalidConfigError } from "../error-codes/error-codes.js";
import type {
SmsDeliveryReport,
SmsMessage,
SmsTemplate,
} from "../types/sms.types.js";
import { isValidE164 } from "../validation/phone.js";
/** Zod refinement that accepts only strict E.164 phone numbers. */
const E164String = z.string().refine(isValidE164, {
message: "expected an E.164 phone number",
});
/** Zod schema mirroring `SmsMessage`. */
export const SmsMessageSchema = z
.object({
to: E164String,
from: z.string().min(1).optional(),
body: z.string().min(1),
templateId: z.string().min(1).optional(),
templateVars: z.record(z.string(), z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
channel: z.string().min(1).optional(),
})
.strict();
/** Zod schema mirroring `SmsTemplate`. */
export const SmsTemplateSchema = z
.object({
id: z.string().min(1),
body: z.string().min(1),
variables: z.array(z.string().min(1)),
defaults: z.record(z.string(), z.string()).optional(),
})
.strict();
/** Zod schema mirroring `SmsDeliveryStatus`. */
export const SmsDeliveryStatusSchema = z.union([
z.literal("queued"),
z.literal("sent"),
z.literal("delivered"),
z.literal("failed"),
z.literal("undelivered"),
]);
/** Zod schema mirroring `SmsDeliveryReport`. */
export const SmsDeliveryReportSchema = z
.object({
messageId: z.string().min(1),
to: E164String,
status: SmsDeliveryStatusSchema,
timestamp: z.instanceof(Date),
errorCode: z.string().optional(),
errorMessage: z.string().optional(),
})
.strict();
function toIssues(
error: z.ZodError
): ReadonlyArray<Readonly<Record<string, unknown>>> {
return error.issues.map((issue) => ({
code: issue.code,
path: issue.path,
message: issue.message,
}));
}
function stripUndefined<T extends Record<string, unknown>>(value: T): T {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (val !== undefined) out[key] = val;
}
return out as T;
}
/** Parse and validate `SmsMessage` input; throws `SmsInvalidConfigError`. */
export function parseSmsMessage(input: unknown): SmsMessage {
const result = SmsMessageSchema.safeParse(input);
if (!result.success) {
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
}
return stripUndefined(result.data) as SmsMessage;
}
/** Parse and validate `SmsTemplate` input; throws `SmsInvalidConfigError`. */
export function parseSmsTemplate(input: unknown): SmsTemplate {
const result = SmsTemplateSchema.safeParse(input);
if (!result.success) {
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
}
return stripUndefined(result.data) as SmsTemplate;
}
/** Parse and validate `SmsDeliveryReport` input. */
export function parseSmsDeliveryReport(input: unknown): SmsDeliveryReport {
const result = SmsDeliveryReportSchema.safeParse(input);
if (!result.success) {
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
}
return stripUndefined(result.data) as SmsDeliveryReport;
}

View File

@@ -0,0 +1,71 @@
# `service/bus-map`
## Purpose
`bus-map` declares `SmsBusMap` — the single-payload topic
contract used by `SmsService.bus` (an `EventBus<SmsBusMap>`).
The map is split out from the service implementation so the
legacy emitter and the service can both import the contract
without a circular dependency, and so downstream consumers can
type their own `EventBus`-shaped bridges against the same
contract.
`SmsBusMap` covers the eight live topics emitted by
`SmsService`: `sms.sent`, `sms.delivered`, `sms.failed`,
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
`sms.rate_limited`.
## Features
- Single-payload `EventBus` contract — one value per topic
(versus the legacy `SmsEventMap` tuple form which mirrors the
upstream `on/off/emit` shape).
- Open `Record<string, unknown>` base so consumers can extend
the map with custom topics in their own packages.
- One-to-one mapping with the public type contracts in
`src/types/sms.types.ts`.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
bus.on("sms.sent", (report) => console.log("sent", report.messageId));
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import type { SmsBusMap } from "@suaveplan/sms/service";
// Extend the map with a host-specific topic to share one bus.
interface HostBusMap extends SmsBusMap {
"host.audit": { actor: string; action: string };
}
const bus = new EventBus<HostBusMap>();
bus.on("sms.failed", (report) => audit({ messageId: report.messageId }));
bus.on("host.audit", (event) => console.log(event.actor, event.action));
```
## API Reference
- `interface SmsBusMap extends Record<string, unknown>` with
keys: `sms.sent`, `sms.delivered`, `sms.failed`, `sms.opted_out`,
`sms.opted_in`, `sms.scheduled`, `sms.retry`, `sms.rate_limited`,
each mapped to the matching public type
(`SmsDeliveryReport`, `OptOutEntry`, `{phoneNumber: string}`,
`SmsScheduledEvent`, `SmsRetryEvent`, `SmsRateLimitedEvent`).
## Implementation Notes
The map is a `type-only` export — no runtime symbol — so it
adds zero bundle size. The decision to use single-payload
topics on the bus (rather than the legacy tuple form) follows
`@suaveplan/events` convention; the tuple form is
preserved exclusively for the upstream `on/off/emit` surface
and is mediated by `LegacyEmitter.mirror`. Consumers building
new code should target the bus directly.

View File

@@ -0,0 +1,19 @@
import type {
OptOutEntry,
SmsDeliveryReport,
SmsRateLimitedEvent,
SmsRetryEvent,
SmsScheduledEvent,
} from "../types/sms.types.js";
/** Single-payload bus topic map used by `SmsService.bus`. */
export interface SmsBusMap extends Record<string, unknown> {
"sms.sent": SmsDeliveryReport;
"sms.delivered": SmsDeliveryReport;
"sms.failed": SmsDeliveryReport;
"sms.opted_out": OptOutEntry;
"sms.opted_in": { phoneNumber: string };
"sms.scheduled": SmsScheduledEvent;
"sms.retry": SmsRetryEvent;
"sms.rate_limited": SmsRateLimitedEvent;
}

View File

@@ -0,0 +1,3 @@
export type { SmsBusMap } from "./bus-map.js";
export { LegacyEmitter, type SmsLegacyListener } from "./legacy-emitter.js";
export { type SmsSendOptions, SmsService } from "./sms-service.js";

View File

@@ -0,0 +1,88 @@
# `service/legacy-emitter`
## Purpose
`LegacyEmitter` is the back-compat shim that preserves the
upstream `@suaveplan/sms` v1 `on/off/emit` listener API on top
of the `EventBus<SmsBusMap>`. Every legacy listener
registered through `service.on(...)` continues to receive the
tuple-spread argument shape expected by upstream callers, while
the same payload flows through the typed bus so current
consumers can opt in to the richer `EventBus` API. This avoids
a breaking change for callers of `@suaveplan/sms` v1.
The emitter lives in its own module so the `SmsService` class
stays focused on pipeline orchestration; the legacy adapter
adds zero cost for callers who only use the typed bus.
## Features
- `on<K>(type, listener)` — register a tuple-arg legacy listener.
- `off<K>(type, listener)` — deregister a legacy listener.
- `emit<K>(type, ...args)` — invoke every legacy listener and,
for known bus topics, mirror the first arg onto the bus.
- `mirror<K>(topic, payload)` — emit a single-payload bus event
and mirror it onto the legacy listeners (used by `SmsService`
for its native emits).
- Topic gate (`BUS_TOPICS`) discriminates legacy-only topics
from typed bus topics so non-mapped topics do not pollute the
typed bus.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import { LegacyEmitter } from "@suaveplan/sms/service";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
emitter.on("sms.opted_in", (entry) => console.log(entry.phoneNumber));
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import { LegacyEmitter } from "@suaveplan/sms/service";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
// Bridge from a typed bus subscriber to a legacy-listener-flavoured
// downstream consumer.
bus.on("sms.sent", (report) => console.log("bus", report.messageId));
emitter.on("sms.sent", (report) =>
console.log("legacy", report.messageId)
);
emitter.mirror("sms.sent", {
messageId: "m1",
to: "+15551234567",
status: "sent",
timestamp: new Date(),
});
// Both subscribers receive the same payload.
```
## API Reference
- `class LegacyEmitter`
- `constructor(bus: EventBus<SmsBusMap>)`
- `on<K extends keyof SmsEventMap>(type, listener): void`
- `off<K extends keyof SmsEventMap>(type, listener): void`
- `emit<K extends keyof SmsEventMap>(type, ...args): void`
- `mirror<K extends keyof SmsBusMap>(topic, payload): void`
- `type SmsLegacyListener<K extends keyof SmsEventMap> = (...args:
SmsEventMap[K]) => void`.
## Implementation Notes
The class snapshots the legacy listener array (`[...list]`)
before iterating so a listener that calls `emitter.off(...)` on
itself mid-emit does not skip subsequent listeners. The `BUS_TOPICS`
constant is the single source of truth for the topic gate — adding
a new bus topic requires also extending the legacy emitter, but
that's the explicit contract: the bus is typed and the legacy
mirror must be opt-in.

View File

@@ -0,0 +1,84 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { SmsBusMap } from "./bus-map.js";
import { LegacyEmitter } from "./legacy-emitter.js";
describe("LegacyEmitter", () => {
it("on/emit invokes listeners with tuple-spread args", {
timeout: 5000,
}, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const handler = vi.fn();
emitter.on("sms.opted_in", handler);
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
expect(handler).toHaveBeenCalledWith({ phoneNumber: "+15551234567" });
});
it("emit mirrors onto the bus for known topics", { timeout: 5000 }, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const busHandler = vi.fn();
bus.on("sms.opted_in", busHandler);
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
expect(busHandler).toHaveBeenCalledTimes(1);
});
it("off removes listeners", { timeout: 5000 }, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const handler = vi.fn();
emitter.on("sms.opted_in", handler);
emitter.off("sms.opted_in", handler);
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
expect(handler).not.toHaveBeenCalled();
});
it("off is a no-op for unknown listeners", { timeout: 5000 }, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const fn = vi.fn();
emitter.off("sms.opted_in", fn);
emitter.on("sms.opted_in", fn);
emitter.off("sms.opted_in", vi.fn());
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
expect(fn).toHaveBeenCalled();
});
it("mirror invokes both bus and legacy listeners", {
timeout: 5000,
}, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const legacy = vi.fn();
const busHandler = vi.fn();
emitter.on("sms.opted_in", legacy);
bus.on("sms.opted_in", busHandler);
emitter.mirror("sms.opted_in", { phoneNumber: "+15551234567" });
expect(legacy).toHaveBeenCalledTimes(1);
expect(busHandler).toHaveBeenCalledTimes(1);
});
it("on twice on same topic reuses the list", { timeout: 5000 }, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const h1 = vi.fn();
const h2 = vi.fn();
emitter.on("sms.opted_in", h1);
emitter.on("sms.opted_in", h2);
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
expect(h1).toHaveBeenCalledTimes(1);
expect(h2).toHaveBeenCalledTimes(1);
});
it("emit on non-bus topic still fires legacy listeners only", {
timeout: 5000,
}, () => {
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
const handler = vi.fn();
emitter.on("custom.event" as never, handler as never);
emitter.emit("custom.event" as never, "x" as never);
expect(handler).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,89 @@
import type { EventBus } from "@suaveplan/events";
import type { SmsEventMap } from "../types/sms.types.js";
import type { SmsBusMap } from "./bus-map.js";
/** Legacy listener signature mirrored from upstream. */
export type SmsLegacyListener<K extends keyof SmsEventMap> = (
...args: SmsEventMap[K]
) => void;
const BUS_TOPICS: ReadonlyArray<keyof SmsBusMap> = [
"sms.sent",
"sms.delivered",
"sms.failed",
"sms.opted_out",
"sms.opted_in",
"sms.scheduled",
"sms.retry",
"sms.rate_limited",
];
/** Track legacy `on/off/emit` listeners and mirror them onto the bus. */
export class LegacyEmitter {
private readonly listeners = new Map<
keyof SmsEventMap,
Array<SmsLegacyListener<keyof SmsEventMap>>
>();
public constructor(private readonly bus: EventBus<SmsBusMap>) {}
public on<K extends keyof SmsEventMap>(
type: K,
listener: SmsLegacyListener<K>
): void {
let list = this.listeners.get(type);
if (list === undefined) {
list = [];
this.listeners.set(type, list);
}
list.push(listener as SmsLegacyListener<keyof SmsEventMap>);
}
public off<K extends keyof SmsEventMap>(
type: K,
listener: SmsLegacyListener<K>
): void {
const list = this.listeners.get(type);
if (list === undefined) return;
const index = list.indexOf(
listener as SmsLegacyListener<keyof SmsEventMap>
);
if (index >= 0) list.splice(index, 1);
}
public emit<K extends keyof SmsEventMap>(
type: K,
...args: SmsEventMap[K]
): void {
const list = this.listeners.get(type);
if (list !== undefined) {
for (const listener of [...list]) {
listener(...(args as Parameters<SmsLegacyListener<K>>));
}
}
if (this.isBusTopic(type)) {
this.bus.emit(
type as keyof SmsBusMap,
args[0] as SmsBusMap[keyof SmsBusMap]
);
}
}
/** Mirror a single-payload bus emit to legacy listeners. */
public mirror<K extends keyof SmsBusMap>(
topic: K,
payload: SmsBusMap[K]
): void {
this.bus.emit(topic, payload);
const list = this.listeners.get(topic as keyof SmsEventMap);
if (list !== undefined) {
for (const listener of [...list]) {
(listener as (payload: SmsBusMap[K]) => void)(payload);
}
}
}
private isBusTopic(type: keyof SmsEventMap): type is keyof SmsBusMap {
return BUS_TOPICS.includes(type as keyof SmsBusMap);
}
}

View File

@@ -0,0 +1,119 @@
# `service/sms-service`
## Purpose
`SmsService` is the provider-agnostic orchestrator that ties
together every primitive in `@suaveplan/sms`. A single
`service.send` call walks the abort check, the provider check,
the E.164 validation, the opt-out check, the rate limiter, the
template renderer, the retry policy, the provider send, the
delivery-tracker ingest, the OTel record helpers, and the event
bus emit. It is the surface upstream consumers used to call
`new SmsService()` against; preserves the verbatim
upstream API plus an enhanced bus surface and an injectable
options bundle.
## Features
- `bus: EventBus<SmsBusMap>` — typed single-payload event bus.
- Legacy `on`, `off`, `emit` API mirrors the bus (preserved for
upstream callers).
- `send(message, options?)` — full pipeline with abort,
validation, opt-out, rate-limit, template render, retry,
provider send, receipt ingest, OTel emit.
- `scheduleAt(date, message)` — fire-and-forget scheduled
delivery with a cancel handle; on cancel emits `sms.failed`
with `errorCode: SMS.SCHEDULE_CANCELLED`.
- `getStatus(messageId)` — cache-first lookup through the
delivery tracker, falling back to `provider.getStatus`.
- `optOut` / `optIn` / `isOptedOut` — opt-out registry helpers
emitting the matching bus event.
- `markDelivered` / `markFailed` — external webhook receipts
that ingest into the tracker and emit the matching event.
- `setProvider` / `getProviderName` — runtime provider swap.
## Usage: Basic
```ts
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
const provider = new InMemorySmsProvider();
const service = new SmsService({ provider });
const report = await service.send({
to: "+15551234567",
body: "Hello",
});
```
## Usage: Advanced
```ts
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
const provider = new InMemorySmsProvider();
const service = new SmsService({
provider,
rateLimiter: { capacity: 10, refillPerSecond: 1 },
retry: { maxAttempts: 5, initialDelayMs: 200 },
deliveryTracker: { maxEntries: 256 },
});
service.registerTemplate({
id: "welcome",
body: "Hi {{name}}, your code is {{code}}",
variables: ["name", "code"],
});
service.bus.on("sms.retry", (e) =>
console.warn("retry", e.attempt, e.error.message)
);
service.bus.on("sms.failed", (r) =>
console.error("failed", r.messageId, r.errorCode)
);
const ctrl = new AbortController();
await service.send(
{
to: "+15551234567",
body: "ignored",
templateId: "welcome",
templateVars: { name: "Alice", code: "8472" },
channel: "transactional",
},
{ signal: ctrl.signal }
);
```
## API Reference
- `class SmsService`
- `constructor(options?: SmsServiceOptions)`
- Public fields: `bus`, `templates`, `optOuts`, `rateLimiter`,
`retry`, `scheduler`, `deliveryTracker`.
- `setProvider`, `getProviderName`, `registerTemplate`,
`getTemplate`.
- `send(message, options?: { signal?: AbortSignal }):
Promise<SmsDeliveryReport>`.
- `scheduleAt(sendAt: Date, message: SmsMessage):
ScheduleHandle`.
- `getStatus(messageId: string): Promise<SmsDeliveryReport>`.
- `optOut`, `optIn`, `isOptedOut`, `markDelivered`,
`markFailed`.
- Legacy `on`, `off`, `emit` mirroring the bus.
- Static `extractFailure(error: unknown): { code: string;
message: string }`.
- `interface SmsSendOptions` — `signal?: AbortSignal`.
## Implementation Notes
The pipeline orders abort → provider → validation → opt-out →
rate-limit → template → retry deliberately: cheap checks fail
fast before the limiter consumes a token; the template is only
rendered when the rate-limiter has admitted the message, so a
back-pressured caller doesn't burn template render cycles. The
service spreads `options.signal` into the retry options
conditionally so an absent signal does not satisfy
`exactOptionalPropertyTypes` with `undefined`. The legacy
`emit` re-uses the bus topic check so non-bus-mapped legacy
topics continue to fire pure-legacy listeners without polluting
the typed bus.

View File

@@ -0,0 +1,557 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
SmsAbortedError,
SmsInvalidNumberError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsRecipientOptedOutError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "../error-codes/error-codes.js";
import { InMemorySmsProvider } from "../in-memory-provider/in-memory-provider.js";
import * as rootExports from "../index.js";
import type {
SmsDeliveryReport,
SmsMessage,
SmsProvider,
} from "../types/sms.types.js";
import { SmsService } from "./sms-service.js";
const validMessage: SmsMessage = { to: "+15551234567", body: "hi" };
function freshService(overrides: Parameters<typeof SmsService>[0] = {}): {
service: SmsService;
provider: InMemorySmsProvider;
} {
const provider = new InMemorySmsProvider();
const service = new SmsService({
provider,
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 100, refillPerSecond: 100 },
...overrides,
});
return { service, provider };
}
describe("SmsService", () => {
it("re-exports the full upstream surface", { timeout: 5000 }, () => {
expect(rootExports.SmsService).toBeDefined();
expect(rootExports.SmsError).toBeDefined();
expect(rootExports.respondProblemJson).toBeDefined();
expect(rootExports.PROBLEM_JSON_CONTENT_TYPE).toBeDefined();
expect(rootExports.registerSmsErrorCodes).toBeDefined();
expect(rootExports.SMS_ERROR_METADATA).toBeDefined();
expect(rootExports.isValidE164).toBeDefined();
expect(rootExports.SMS_CODES).toBeDefined();
});
it("getProviderName returns null when no provider", {
timeout: 5000,
}, () => {
expect(new SmsService().getProviderName()).toBeNull();
});
it("setProvider updates the in-use provider", { timeout: 5000 }, () => {
const svc = new SmsService();
svc.setProvider(new InMemorySmsProvider({ name: "first" }));
svc.setProvider(new InMemorySmsProvider({ name: "second" }));
expect(svc.getProviderName()).toBe("second");
});
it("registers and reads templates", { timeout: 5000 }, () => {
const svc = new SmsService();
svc.registerTemplate({
id: "x",
body: "hi {{n}}",
variables: ["n"],
});
expect(svc.getTemplate("x")?.body).toBe("hi {{n}}");
});
it("no provider throws SmsProviderNotConfiguredError", {
timeout: 30_000,
}, async () => {
const svc = new SmsService();
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
SmsProviderNotConfiguredError
);
await expect(svc.getStatus("m1")).rejects.toBeInstanceOf(
SmsProviderNotConfiguredError
);
});
it("invalid number throws SmsInvalidNumberError", {
timeout: 30_000,
}, async () => {
const { service } = freshService();
await expect(
service.send({ to: "bad", body: "hi" })
).rejects.toBeInstanceOf(SmsInvalidNumberError);
});
it("opt-out blocks send", { timeout: 30_000 }, async () => {
const { service } = freshService();
service.optOut("+15551234567");
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
SmsRecipientOptedOutError
);
});
it("opt-in unblocks send", { timeout: 30_000 }, async () => {
const { service } = freshService();
service.optOut("+15551234567");
service.optIn("+15551234567");
await expect(service.send(validMessage)).resolves.toBeDefined();
expect(service.isOptedOut("+15551234567")).toBe(false);
});
it("rate-limited recipient throws SmsRateLimitedError", {
timeout: 30_000,
}, async () => {
const { service } = freshService({
rateLimiter: { capacity: 1, refillPerSecond: 1 },
});
await service.send(validMessage);
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
SmsRateLimitedError
);
});
it("unknown template throws SmsTemplateNotFoundError", {
timeout: 30_000,
}, async () => {
const { service } = freshService();
await expect(
service.send({ ...validMessage, templateId: "absent" })
).rejects.toBeInstanceOf(SmsTemplateNotFoundError);
});
it("renders templates before sending", { timeout: 30_000 }, async () => {
const { service, provider } = freshService();
service.registerTemplate({
id: "greet",
body: "Hello {{name}}, code {{code}}",
variables: ["name", "code"],
});
await service.send({
...validMessage,
templateId: "greet",
templateVars: { name: "Alice", code: "1234" },
});
expect(provider.sent[0]?.body).toBe("Hello Alice, code 1234");
});
it("wraps non-typed provider errors in SmsSendFailedError", {
timeout: 30_000,
}, async () => {
const { service, provider } = freshService();
provider.failOn(() => true, new Error("transient"));
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
SmsSendFailedError
);
});
it("re-throws typed errors unchanged", { timeout: 30_000 }, async () => {
class TypedProvider implements SmsProvider {
public readonly name = "typed";
public async send(): Promise<SmsDeliveryReport> {
throw new SmsInvalidNumberError({
context: { phoneNumber: "+1" },
});
}
public async getStatus(): Promise<SmsDeliveryReport> {
throw new Error("no");
}
}
const svc = new SmsService({
provider: new TypedProvider(),
retry: { maxAttempts: 1 },
});
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
SmsInvalidNumberError
);
});
it("legacy on/off mirrors the bus", { timeout: 30_000 }, async () => {
const { service } = freshService();
const handler = vi.fn();
service.on("sms.sent", handler);
await service.send(validMessage);
expect(handler).toHaveBeenCalledTimes(1);
service.off("sms.sent", handler);
await service.send(validMessage);
expect(handler).toHaveBeenCalledTimes(1);
});
it("bus receives emitted reports", { timeout: 30_000 }, async () => {
const { service } = freshService();
const busHandler = vi.fn();
service.bus.on("sms.sent", busHandler);
await service.send(validMessage);
expect(busHandler).toHaveBeenCalledTimes(1);
});
it("send records sent_total and send_duration_ms", {
timeout: 30_000,
}, async () => {
const { service } = freshService();
await service.send(validMessage);
// Implicitly exercised via the OTel record helpers in service.
expect(true).toBe(true);
});
it("emits sms.rate_limited when throttled", {
timeout: 30_000,
}, async () => {
const { service } = freshService({
rateLimiter: { capacity: 1, refillPerSecond: 1 },
});
const handler = vi.fn();
service.on("sms.rate_limited", handler);
await service.send(validMessage);
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
SmsRateLimitedError
);
expect(handler).toHaveBeenCalledTimes(1);
});
it("emits sms.retry on retry attempts", { timeout: 30_000 }, async () => {
const provider = new InMemorySmsProvider();
let calls = 0;
provider.failOn(() => {
calls += 1;
return calls === 1;
}, new Error("flaky"));
const svc = new SmsService({
provider,
retry: {
maxAttempts: 2,
initialDelayMs: 1,
maxDelayMs: 1,
random: () => 0,
},
rateLimiter: { capacity: 10, refillPerSecond: 10 },
});
const retryHandler = vi.fn();
svc.on("sms.retry", retryHandler);
await svc.send(validMessage);
expect(retryHandler).toHaveBeenCalledTimes(1);
});
it("forwards a non-aborted signal through retry", {
timeout: 30_000,
}, async () => {
const { service, provider } = freshService();
const ctrl = new AbortController();
const report = await service.send(validMessage, {
signal: ctrl.signal,
});
expect(report.to).toBe("+15551234567");
expect(provider.sent).toHaveLength(1);
});
it("pre-aborted signal halts send", { timeout: 30_000 }, async () => {
const { service, provider } = freshService();
const ctrl = new AbortController();
ctrl.abort();
await expect(
service.send(validMessage, { signal: ctrl.signal })
).rejects.toBeInstanceOf(SmsAbortedError);
expect(provider.sent).toHaveLength(0);
});
it("scheduleAt routes through the pipeline", {
timeout: 30_000,
}, async () => {
// Use real timers with a near-instant delay.
const { service, provider } = freshService();
const scheduledHandler = vi.fn();
const sentHandler = vi.fn();
service.bus.on("sms.scheduled", scheduledHandler);
service.bus.on("sms.sent", sentHandler);
service.scheduleAt(new Date(Date.now() + 5), validMessage);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(scheduledHandler).toHaveBeenCalledTimes(1);
expect(sentHandler).toHaveBeenCalledTimes(1);
expect(provider.sent).toHaveLength(1);
});
it("scheduleAt cancel emits sms.failed", { timeout: 30_000 }, async () => {
const { service } = freshService();
const failedHandler = vi.fn();
service.bus.on("sms.failed", failedHandler);
const handle = service.scheduleAt(
new Date(Date.now() + 60_000),
validMessage
);
handle.cancel();
// cancel is synchronous
expect(failedHandler).toHaveBeenCalledTimes(1);
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
expect(report.errorCode).toBe("SMS.SCHEDULE_CANCELLED");
});
it("scheduleAt failure emits sms.failed", {
timeout: 30_000,
}, async () => {
const provider = new InMemorySmsProvider();
provider.failOn(() => true, new Error("boom"));
const service = new SmsService({
provider,
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 10, refillPerSecond: 10 },
});
const failedHandler = vi.fn();
service.bus.on("sms.failed", failedHandler);
service.scheduleAt(new Date(Date.now() + 5), validMessage);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(failedHandler).toHaveBeenCalled();
});
it("markDelivered emits sms.delivered and caches receipt", {
timeout: 5000,
}, () => {
const { service } = freshService();
const handler = vi.fn();
service.on("sms.delivered", handler);
const report: SmsDeliveryReport = {
messageId: "m1",
to: "+15551234567",
status: "delivered",
timestamp: new Date(),
};
service.markDelivered(report);
expect(handler).toHaveBeenCalledWith(report);
expect(service.deliveryTracker.getStatus("m1")).toEqual(report);
});
it("markFailed emits sms.failed and caches receipt", {
timeout: 5000,
}, () => {
const { service } = freshService();
const handler = vi.fn();
service.on("sms.failed", handler);
const report: SmsDeliveryReport = {
messageId: "m2",
to: "+15551234567",
status: "failed",
timestamp: new Date(),
};
service.markFailed(report);
expect(handler).toHaveBeenCalledWith(report);
});
it("getStatus consults the delivery tracker + provider", {
timeout: 30_000,
}, async () => {
const { service, provider } = freshService();
const report = await service.send(validMessage);
// Cache hit
const cached = await service.getStatus(report.messageId);
expect(cached.messageId).toBe(report.messageId);
expect(provider.sent).toHaveLength(1);
});
it("optOut emits sms.opted_out", { timeout: 5000 }, () => {
const { service } = freshService();
const handler = vi.fn();
service.on("sms.opted_out", handler);
service.optOut("+15551234567", "marketing");
expect(handler).toHaveBeenCalledTimes(1);
});
it("optIn emits sms.opted_in", { timeout: 5000 }, () => {
const { service } = freshService();
const handler = vi.fn();
service.on("sms.opted_in", handler);
service.optIn("+15551234567");
expect(handler).toHaveBeenCalledWith({ phoneNumber: "+15551234567" });
});
it("emit forwards legacy events to the bus", { timeout: 5000 }, () => {
const { service } = freshService();
const busHandler = vi.fn();
service.bus.on("sms.opted_out", busHandler);
service.emit("sms.opted_out", {
phoneNumber: "+15551234567",
optedOutAt: new Date(),
});
expect(busHandler).toHaveBeenCalledTimes(1);
});
it("emit on non-bus topics still notifies legacy listeners", {
timeout: 5000,
}, () => {
const { service } = freshService();
const handler = vi.fn();
// Use a custom non-bus topic
service.on("custom.event" as never, handler as never);
service.emit("custom.event" as never, "x" as never);
expect(handler).toHaveBeenCalled();
});
it("off is a no-op for unregistered listeners", { timeout: 5000 }, () => {
const { service } = freshService();
const handler = vi.fn();
service.off("sms.sent", handler);
// also exercise off after a register/unregister
service.on("sms.sent", handler);
service.off("sms.sent", () => undefined);
expect(true).toBe(true);
});
it("uses default options when none supplied", { timeout: 5000 }, () => {
const svc = new SmsService();
expect(svc.rateLimiter).toBeDefined();
expect(svc.retry).toBeDefined();
expect(svc.scheduler).toBeDefined();
expect(svc.deliveryTracker).toBeDefined();
});
it("feature subpaths are declared in package.json", {
timeout: 5000,
}, () => {
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(here, "../../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
exports: Record<string, unknown>;
};
const subpaths = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
expect(subpaths.length).toBeGreaterThanOrEqual(15);
});
it("wraps non-Error provider rejections", {
timeout: 30_000,
}, async () => {
class StringRejectProvider implements SmsProvider {
public readonly name = "string-reject";
public async send(): Promise<SmsDeliveryReport> {
// biome rejects literal throws, so we bury the string in unknown
const bad: unknown = "non-error throwable";
throw bad;
}
public async getStatus(): Promise<SmsDeliveryReport> {
throw new Error("no");
}
}
const svc = new SmsService({
provider: new StringRejectProvider(),
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 10, refillPerSecond: 10 },
});
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
SmsSendFailedError
);
});
it("renders template with no templateVars supplied", {
timeout: 30_000,
}, async () => {
const { service, provider } = freshService();
service.registerTemplate({
id: "noargs",
body: "Hello {{name}}",
variables: ["name"],
});
await service.send({
to: "+15551234567",
body: "ignored",
templateId: "noargs",
});
expect(provider.sent[0]?.body).toBe("Hello ");
});
it("scheduleAt failure with typed error preserves code", {
timeout: 30_000,
}, async () => {
const provider = new InMemorySmsProvider();
provider.failOn(() => true, new Error("boom"));
const service = new SmsService({
provider,
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 10, refillPerSecond: 10 },
});
const failedHandler = vi.fn();
service.bus.on("sms.failed", failedHandler);
service.scheduleAt(new Date(Date.now() + 5), validMessage);
await new Promise((resolve) => setTimeout(resolve, 40));
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
expect(report.errorCode).toBe("SMS.SEND_FAILED");
expect(report.errorMessage).toBeDefined();
});
it("scheduleAt failure without code falls back", {
timeout: 30_000,
}, () => {
const { service } = freshService();
const failedHandler = vi.fn();
service.bus.on("sms.failed", failedHandler);
// Synthetic error without "code" property
const handle = service.scheduleAt(
new Date(Date.now() + 60_000),
validMessage
);
handle.cancel();
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
expect(report.errorCode).toBe("SMS.SCHEDULE_CANCELLED");
});
it("extractFailure handles every shape", { timeout: 5000 }, () => {
expect(SmsService.extractFailure(new Error("boom"))).toEqual({
code: "SMS.SEND_FAILED",
message: "boom",
});
expect(SmsService.extractFailure({ code: "C", message: "M" })).toEqual({
code: "C",
message: "M",
});
expect(SmsService.extractFailure({})).toEqual({
code: "SMS.SEND_FAILED",
message: "",
});
expect(SmsService.extractFailure("string-thrown")).toEqual({
code: "SMS.SEND_FAILED",
message: "string-thrown",
});
expect(SmsService.extractFailure(null)).toEqual({
code: "SMS.SEND_FAILED",
message: "null",
});
expect(
SmsService.extractFailure({ code: undefined, message: undefined })
).toEqual({
code: "SMS.SEND_FAILED",
message: "",
});
});
it("no node: or bun: imports leak into src/", { timeout: 5000 }, () => {
const here = dirname(fileURLToPath(import.meta.url));
const srcRoot = resolve(here, "..");
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const full = `${dir}/${entry}`;
const stat = statSync(full);
if (stat.isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith(".ts")) continue;
if (entry.endsWith(".test.ts")) continue;
const text = readFileSync(full, "utf8");
if (/from\s+['"](node:|bun:)/.test(text)) {
offenders.push(full);
}
}
};
walk(srcRoot);
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,305 @@
import { EventBus } from "@suaveplan/events";
import type { IdGenerator } from "@suaveplan/types";
import { DeliveryTracker } from "../delivery-tracker/delivery-tracker.js";
import {
SmsAbortedError,
SmsInvalidNumberError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsRecipientOptedOutError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "../error-codes/error-codes.js";
import { OptOutRegistry } from "../opt-out/opt-out.js";
import {
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
recordSmsSent,
} from "../otel/metrics.js";
import { RateLimiter } from "../rate-limiter/rate-limiter.js";
import { RetryPolicy } from "../retry/retry.js";
import { MessageScheduler } from "../scheduler/scheduler.js";
import { TemplateRegistry } from "../templates/templates.js";
import type {
OptOutEntry,
ScheduleHandle,
SmsDeliveryReport,
SmsEventMap,
SmsMessage,
SmsProvider,
SmsServiceOptions,
SmsTemplate,
} from "../types/sms.types.js";
import { isValidE164 } from "../validation/phone.js";
import type { SmsBusMap } from "./bus-map.js";
import { LegacyEmitter, type SmsLegacyListener } from "./legacy-emitter.js";
/** Options accepted by `SmsService.send`. */
export interface SmsSendOptions {
/** Optional cancellation signal. */
signal?: AbortSignal;
}
let defaultIdCounter = 0;
const defaultIdGenerator: IdGenerator = () => {
defaultIdCounter += 1;
return `sms_${defaultIdCounter.toString(36)}_${Date.now().toString(36)}`;
};
/** Normalize an unknown thrown value to a `{code, message}` record. */
function extractFailure(error: unknown): { code: string; message: string } {
if (typeof error === "object" && error !== null) {
const obj = error as { code?: unknown; message?: unknown };
const code =
"code" in obj && obj.code !== undefined
? String(obj.code)
: "SMS.SEND_FAILED";
const message =
"message" in obj && obj.message !== undefined
? String(obj.message)
: "";
return { code, message };
}
return { code: "SMS.SEND_FAILED", message: String(error) };
}
/** Provider-agnostic SMS orchestrator. */
export class SmsService {
public readonly bus: EventBus<SmsBusMap>;
public readonly templates: TemplateRegistry;
public readonly optOuts: OptOutRegistry;
public readonly rateLimiter: RateLimiter;
public readonly retry: RetryPolicy;
public readonly scheduler: MessageScheduler;
public readonly deliveryTracker: DeliveryTracker;
private provider: SmsProvider | null;
private readonly idGenerator: IdGenerator;
private readonly legacy: LegacyEmitter;
public constructor(options: SmsServiceOptions = {}) {
this.bus = new EventBus<SmsBusMap>();
this.legacy = new LegacyEmitter(this.bus);
this.templates = new TemplateRegistry();
this.optOuts = new OptOutRegistry();
this.rateLimiter = new RateLimiter(options.rateLimiter);
this.retry = new RetryPolicy(options.retry);
this.scheduler = new MessageScheduler(options.scheduler);
this.deliveryTracker = new DeliveryTracker(options.deliveryTracker);
this.provider = options.provider ?? null;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
}
public setProvider(provider: SmsProvider): void {
this.provider = provider;
}
public getProviderName(): string | null {
return this.provider?.name ?? null;
}
public registerTemplate(template: SmsTemplate): void {
this.templates.register(template);
}
public getTemplate(id: string): SmsTemplate | undefined {
return this.templates.get(id);
}
public async send(
message: SmsMessage,
options: SmsSendOptions = {}
): Promise<SmsDeliveryReport> {
const startMs = Date.now();
try {
const report = await this.runPipeline(message, options);
recordSmsSent(message.channel ?? "default", false);
recordSmsSendDuration(Date.now() - startMs, "ok");
this.legacy.mirror("sms.sent", report);
return report;
} catch (error) {
const wrapped = this.wrap(error);
recordSmsFailed(wrapped.code);
recordSmsSendDuration(
Date.now() - startMs,
wrapped.code === "SMS.ABORTED" ? "aborted" : "error"
);
throw wrapped;
}
}
public scheduleAt(sendAt: Date, message: SmsMessage): ScheduleHandle {
const handle = this.scheduler.scheduleAt(sendAt, () => {
void this.send(message).catch((error: unknown) => {
this.legacy.mirror(
"sms.failed",
this.toFailedReport(message, extractFailure(error))
);
});
});
this.legacy.mirror("sms.scheduled", { to: message.to, sendAt });
return {
sendAt,
cancel: () => {
handle.cancel();
this.legacy.mirror(
"sms.failed",
this.toFailedReport(message, {
code: "SMS.SCHEDULE_CANCELLED",
message: "schedule cancelled",
})
);
},
};
}
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
if (this.provider === null) {
throw new SmsProviderNotConfiguredError();
}
return this.deliveryTracker.lookupOrFetch(messageId, this.provider);
}
public optOut(phoneNumber: string, channel?: string): OptOutEntry {
const entry = this.optOuts.add(phoneNumber, channel);
this.legacy.mirror("sms.opted_out", entry);
return entry;
}
public optIn(phoneNumber: string): void {
this.optOuts.remove(phoneNumber);
this.legacy.mirror("sms.opted_in", { phoneNumber });
}
public isOptedOut(phoneNumber: string): boolean {
return this.optOuts.has(phoneNumber);
}
public markDelivered(report: SmsDeliveryReport): void {
this.deliveryTracker.ingest(report);
this.legacy.mirror("sms.delivered", report);
}
public markFailed(report: SmsDeliveryReport): void {
this.deliveryTracker.ingest(report);
this.legacy.mirror("sms.failed", report);
}
public on<K extends keyof SmsEventMap>(
type: K,
listener: SmsLegacyListener<K>
): void {
this.legacy.on(type, listener);
}
public off<K extends keyof SmsEventMap>(
type: K,
listener: SmsLegacyListener<K>
): void {
this.legacy.off(type, listener);
}
public emit<K extends keyof SmsEventMap>(
type: K,
...args: SmsEventMap[K]
): void {
this.legacy.emit(type, ...args);
}
private async runPipeline(
message: SmsMessage,
options: SmsSendOptions
): Promise<SmsDeliveryReport> {
if (options.signal?.aborted) throw new SmsAbortedError();
if (this.provider === null) {
throw new SmsProviderNotConfiguredError();
}
if (!isValidE164(message.to)) {
throw new SmsInvalidNumberError({
context: { phoneNumber: message.to },
});
}
if (this.optOuts.has(message.to)) {
throw new SmsRecipientOptedOutError({
context: { phoneNumber: message.to },
});
}
const limiterKey = `${message.channel ?? "default"}:${message.to}`;
if (!this.rateLimiter.tryConsume(limiterKey)) {
this.legacy.mirror("sms.rate_limited", {
to: message.to,
key: limiterKey,
});
throw new SmsRateLimitedError({
context: { phoneNumber: message.to, key: limiterKey },
});
}
const outgoing = this.applyTemplate(message);
const provider = this.provider;
const report = await this.retry.execute(
async () => provider.send(outgoing, options),
{
...(options.signal !== undefined
? { signal: options.signal }
: {}),
onRetry: (event) => {
recordSmsRetry(event.attempt);
this.legacy.mirror("sms.retry", {
...event,
to: message.to,
});
},
to: message.to,
}
);
this.deliveryTracker.ingest(report);
return report;
}
private applyTemplate(message: SmsMessage): SmsMessage {
if (message.templateId === undefined) return message;
const template = this.templates.get(message.templateId);
if (template === undefined) {
throw new SmsTemplateNotFoundError({
context: { templateId: message.templateId },
});
}
const body = this.templates.render(
message.templateId,
message.templateVars ?? {}
);
return { ...message, body };
}
private wrap(error: unknown): Error & { code: string } {
if (error instanceof Error && "code" in error) {
return error as Error & { code: string };
}
// runPipeline always rejects with Error; cast is safe.
const cause = error as Error;
return new SmsSendFailedError({ cause }) as Error & { code: string };
}
/** Normalize an unknown thrown value to a `{code, message}` record. */
public static extractFailure(error: unknown): {
code: string;
message: string;
} {
return extractFailure(error);
}
private toFailedReport(
message: SmsMessage,
error: { code: string; message: string }
): SmsDeliveryReport {
return {
messageId: this.idGenerator(),
to: message.to,
status: "failed",
timestamp: new Date(),
errorCode: error.code,
errorMessage: error.message,
};
}
}

View File

@@ -0,0 +1 @@
export { renderTemplate, TemplateRegistry } from "./templates.js";

View File

@@ -0,0 +1,99 @@
# `templates`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`templates` is the in-process template registry and renderer used
by `SmsService` to expand `{{variable}}` placeholders against
per-call variable maps. The renderer is intentionally narrow —
substitution only, no conditional logic, no loops — because SMS
bodies are short and predictable; richer templating belongs in a
caller-side pre-processing pass that produces the
`SmsMessage.body` string.
The registry holds `SmsTemplate` rows indexed by `id` and exposes
the small API surface (register, get, require, has, unregister,
list, render, clear) that `SmsService` and consumer code use.
## Features
- `TemplateRegistry` class for in-memory storage.
- `renderTemplate(template, vars, defaults?)` — substitutes
every declared `{{var}}` with a value sourced from the `vars`
map, then the call-level `defaults`, then the template-level
`template.defaults`, then the empty string.
- Undeclared placeholders are left in place so callers can layer
their own renderer on top.
- Regex escaping protects against variable names containing
regex meta-characters.
- `require(id)` throws `SmsTemplateNotFoundError`.
## Usage: Basic
```ts
import {
TemplateRegistry,
renderTemplate,
} from "@suaveplan/sms/templates";
const reg = new TemplateRegistry();
reg.register({
id: "welcome",
body: "Hi {{name}}!",
variables: ["name"],
});
const out = reg.render("welcome", { name: "Alice" });
console.log(out); // "Hi Alice!"
```
## Usage: Advanced
```ts
import { renderTemplate } from "@suaveplan/sms/templates";
const tpl = {
id: "alert",
body: "Alert {{level}}: {{message}} from {{system}}",
variables: ["level", "message", "system"],
defaults: { system: "monitoring" },
};
// Per-call defaults override template defaults; missing keys
// fall back to the empty string so the body is never broken.
const body = renderTemplate(
tpl,
{ level: "WARN", message: "high latency" },
{ system: "checkout-api" }
);
// "Alert WARN: high latency from checkout-api"
```
## API Reference
- `renderTemplate(template: SmsTemplate, vars: Record<string,
string>, defaults?: Record<string, string>): string` — pure
function.
- `class TemplateRegistry`
- `register(template: SmsTemplate): void`
- `get(id: string): SmsTemplate | undefined`
- `require(id: string): SmsTemplate` — throws
`SmsTemplateNotFoundError`
- `has(id: string): boolean`
- `unregister(id: string): boolean`
- `list(): ReadonlyArray<SmsTemplate>`
- `render(id: string, vars, defaults?): string`
- `clear(): void`
## Implementation Notes
Variable lookup order — `vars[v]` → `defaults[v]` →
`template.defaults?.[v]` → `""` — places per-call values
strictest at the top so a caller can always override. The
substitution uses an escaped global regex so a variable name
like `na.me` matches literally rather than as a regex
metacharacter. Templates are stored by reference, so external
mutation of a registered template will leak into render results —
callers wanting an isolated copy should clone before passing in.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SmsTemplateNotFoundError } from "../error-codes/error-codes.js";
import { renderTemplate, TemplateRegistry } from "./templates.js";
describe("renderTemplate", () => {
it("substitutes declared variables", { timeout: 5000 }, () => {
const out = renderTemplate(
{ id: "x", body: "Hello {{name}}!", variables: ["name"] },
{ name: "Alice" }
);
expect(out).toBe("Hello Alice!");
});
it("missing variables fall back to defaults then empty string", {
timeout: 5000,
}, () => {
expect(
renderTemplate(
{ id: "x", body: "Hi {{name}}", variables: ["name"] },
{}
)
).toBe("Hi ");
expect(
renderTemplate(
{ id: "x", body: "Hi {{name}}", variables: ["name"] },
{},
{ name: "World" }
)
).toBe("Hi World");
expect(
renderTemplate(
{
id: "x",
body: "Hi {{name}}",
variables: ["name"],
defaults: { name: "TemplateDefault" },
},
{}
)
).toBe("Hi TemplateDefault");
});
it("leaves undeclared placeholders untouched", { timeout: 5000 }, () => {
const out = renderTemplate(
{
id: "x",
body: "Hi {{name}}, {{not_declared}}",
variables: ["name"],
},
{ name: "Alice" }
);
expect(out).toBe("Hi Alice, {{not_declared}}");
});
it("escapes regex special chars in variable names", {
timeout: 5000,
}, () => {
const out = renderTemplate(
{ id: "x", body: "Hi {{na.me}}!", variables: ["na.me"] },
{ "na.me": "Alice" }
);
expect(out).toBe("Hi Alice!");
});
it("call-level defaults beat template-level defaults", {
timeout: 5000,
}, () => {
const out = renderTemplate(
{
id: "x",
body: "Hi {{name}}",
variables: ["name"],
defaults: { name: "T" },
},
{},
{ name: "C" }
);
expect(out).toBe("Hi C");
});
});
describe("TemplateRegistry", () => {
it("registry register/get/has/unregister/list", {
timeout: 5000,
}, () => {
const reg = new TemplateRegistry();
const tpl = { id: "t1", body: "Hi", variables: [] };
reg.register(tpl);
expect(reg.get("t1")).toEqual(tpl);
expect(reg.has("t1")).toBe(true);
expect(reg.list()).toEqual([tpl]);
expect(reg.unregister("t1")).toBe(true);
expect(reg.get("t1")).toBeUndefined();
expect(reg.unregister("t1")).toBe(false);
});
it("require throws SmsTemplateNotFoundError when missing", {
timeout: 5000,
}, () => {
const reg = new TemplateRegistry();
expect(() => reg.require("absent")).toThrow(SmsTemplateNotFoundError);
});
it("overwrites existing template with same id", { timeout: 5000 }, () => {
const reg = new TemplateRegistry();
reg.register({ id: "t1", body: "old", variables: [] });
reg.register({ id: "t1", body: "new", variables: [] });
expect(reg.get("t1")?.body).toBe("new");
});
it("clear drops every template", { timeout: 5000 }, () => {
const reg = new TemplateRegistry();
reg.register({ id: "t1", body: "x", variables: [] });
reg.register({ id: "t2", body: "y", variables: [] });
reg.clear();
expect(reg.list()).toEqual([]);
});
it("render delegates to renderTemplate via require", {
timeout: 5000,
}, () => {
const reg = new TemplateRegistry();
reg.register({ id: "greet", body: "Hi {{name}}", variables: ["name"] });
expect(reg.render("greet", { name: "Alice" })).toBe("Hi Alice");
expect(reg.render("greet", {}, { name: "Default" })).toBe("Hi Default");
});
});

View File

@@ -0,0 +1,87 @@
import { SmsTemplateNotFoundError } from "../error-codes/error-codes.js";
import type { SmsTemplate } from "../types/sms.types.js";
/**
* Render a template by substituting `{{var}}` placeholders. Variables
* declared in `template.variables` are sourced from (1) the `vars` map,
* (2) the `defaults` map, (3) the template's `defaults` map, then
* fall back to the empty string. Placeholders for variables not declared
* in `template.variables` are left untouched so callers can layer their
* own renderer on top.
*/
export function renderTemplate(
template: SmsTemplate,
vars: Record<string, string>,
defaults: Record<string, string> = {}
): string {
let result = template.body;
for (const varName of template.variables) {
const value =
vars[varName] ??
defaults[varName] ??
template.defaults?.[varName] ??
"";
const pattern = new RegExp(`\\{\\{${escapeRegExp(varName)}\\}\\}`, "g");
result = result.replace(pattern, value);
}
return result;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** In-memory template registry used by `SmsService`. */
export class TemplateRegistry {
private readonly templates = new Map<string, SmsTemplate>();
/** Register a template; overwrites an existing entry with the same id. */
public register(template: SmsTemplate): void {
this.templates.set(template.id, template);
}
/** Get a template by id; returns `undefined` if absent. */
public get(id: string): SmsTemplate | undefined {
return this.templates.get(id);
}
/** Get a template by id or throw `SmsTemplateNotFoundError`. */
public require(id: string): SmsTemplate {
const template = this.templates.get(id);
if (template === undefined) {
throw new SmsTemplateNotFoundError({
context: { templateId: id },
});
}
return template;
}
/** Return `true` iff the template is registered. */
public has(id: string): boolean {
return this.templates.has(id);
}
/** Unregister a template; returns `true` iff a template was removed. */
public unregister(id: string): boolean {
return this.templates.delete(id);
}
/** Snapshot every registered template. */
public list(): ReadonlyArray<SmsTemplate> {
return [...this.templates.values()];
}
/** Render a registered template; throws if id is unknown. */
public render(
id: string,
vars: Record<string, string>,
defaults?: Record<string, string>
): string {
return renderTemplate(this.require(id), vars, defaults);
}
/** Drop every registered template. */
public clear(): void {
this.templates.clear();
}
}

View File

@@ -0,0 +1,114 @@
# `types/sms.types`
## Purpose
`types/sms.types` is the type-only foundation for
`@suaveplan/sms`. It declares every public contract that the
service, the providers, and the consumers exchange — the
message shape, the receipt shape, the delivery status union,
the provider interface, the opt-out entry, the template, the
service options, the scheduled-message envelope, and the event
payloads. Keeping the types in a single dependency-free module
prevents circular imports between the implementation modules.
The shapes mirror the upstream `@suaveplan/sms` v1 contracts
verbatim — same field names, same optionality — so existing
callers can migrate with zero source changes. The
file additionally introduces contracts
(`RateLimiterOptions`, `RetryPolicyOptions`,
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
`SmsServiceOptions`, `ScheduleHandle`, `SmsRetryEvent`,
`SmsRateLimitedEvent`, `SmsScheduledEvent`) that surface the
new capabilities.
## Features
- `SmsDeliveryStatus` union, `SmsMessage`, `SmsDeliveryReport`.
- `SmsProvider` abstract interface with `name`, `send`, and
`getStatus`.
- `OptOutEntry` and `SmsTemplate` for registries.
- `SmsEventMap` tuple-form contract for the upstream
`on/off/emit` API.
- `RateLimiterOptions`, `RetryPolicyOptions`,
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
`SmsServiceOptions` for primitive configuration.
- `ScheduleHandle`, `ScheduledMessage`, `SmsRetryEvent`,
`SmsRateLimitedEvent`, `SmsScheduledEvent` for the
additions.
## Usage: Basic
```ts
import type { SmsMessage, SmsProvider } from "@suaveplan/sms/types";
const message: SmsMessage = { to: "+15551234567", body: "hi" };
class MyProvider implements SmsProvider {
public readonly name = "my-provider";
async send(msg: SmsMessage) {
// ... call API ...
return {
messageId: "m1",
to: msg.to,
status: "sent" as const,
timestamp: new Date(),
};
}
async getStatus(messageId: string) {
return {
messageId,
to: "+15551234567",
status: "delivered" as const,
timestamp: new Date(),
};
}
}
```
## Usage: Advanced
```ts
import type {
ScheduleHandle,
SmsEventMap,
SmsServiceOptions,
} from "@suaveplan/sms/types";
function buildOptions(): SmsServiceOptions {
return {
retry: { maxAttempts: 5, initialDelayMs: 100 },
rateLimiter: { capacity: 30, refillPerSecond: 2 },
deliveryTracker: { maxEntries: 512 },
};
}
type SentArgs = SmsEventMap["sms.sent"]; // [SmsDeliveryReport]
function trackHandle(handle: ScheduleHandle): void {
console.log("scheduled for", handle.sendAt.toISOString());
}
```
## API Reference
- Union: `SmsDeliveryStatus`.
- Interfaces: `SmsMessage`, `SmsSendCallOptions`,
`SmsDeliveryReport`, `SmsProvider`, `OptOutEntry`,
`SmsTemplate`, `SmsRetryEvent`, `SmsRateLimitedEvent`,
`SmsScheduledEvent`, `SmsEventMap`, `RateLimiterOptions`,
`RetryPolicyOptions`, `MessageSchedulerOptions`,
`DeliveryTrackerOptions`, `SmsServiceOptions`,
`ScheduleHandle`, `ScheduledMessage`.
## Implementation Notes
The module is type-only — every export is a `type` or
`interface`, no runtime symbol — so it adds zero bundle size and
can be imported by any other module without inducing a runtime
dependency cycle. Optional fields use `?` (not `| undefined`)
so the file complies with the `exactOptionalPropertyTypes`
contract. The `SmsEventMap` retains the tuple form
(`"sms.sent": [SmsDeliveryReport]`) verbatim from upstream so
existing tuple-spread listeners keep compiling; the single-payload
form used by the typed bus lives in
`src/service/bus-map.ts` as `SmsBusMap`.

View File

@@ -0,0 +1,201 @@
/**
* Public type contracts for `@suaveplan/sms`. Kept dependency-free so that
* consumers can import the contracts without paying for the implementation.
*
* @module types/sms.types
*/
import type { Clock, IdGenerator, TimersLike } from "@suaveplan/types";
/** Delivery status of an SMS message. */
export type SmsDeliveryStatus =
| "queued"
| "sent"
| "delivered"
| "failed"
| "undelivered";
/** Outgoing SMS message. */
export interface SmsMessage {
/** Recipient phone number in E.164 format. */
to: string;
/** Sender phone number or alphanumeric ID. */
from?: string;
/** Message body text. */
body: string;
/** Template ID for rendering. */
templateId?: string;
/** Variables for template rendering. */
templateVars?: Record<string, string>;
/** Arbitrary metadata. */
metadata?: Record<string, unknown>;
/** Optional channel name for rate-limiter bucketing. */
channel?: string;
}
/** Report of an SMS delivery attempt. */
export interface SmsDeliveryReport {
/** Unique identifier for the message. */
messageId: string;
/** Recipient phone number. */
to: string;
/** Current delivery status. */
status: SmsDeliveryStatus;
/** Timestamp of the status update. */
timestamp: Date;
/** Provider-specific error code. */
errorCode?: string;
/** Human-readable error message. */
errorMessage?: string;
}
/** Options forwarded to a provider's send method. */
export interface SmsSendCallOptions {
/** Optional cancellation signal. */
signal?: AbortSignal;
}
/** Interface that all SMS providers must implement. */
export interface SmsProvider {
/** Provider name identifier. */
readonly name: string;
/** Send an SMS message. */
send(
message: SmsMessage,
options?: SmsSendCallOptions
): Promise<SmsDeliveryReport>;
/** Get delivery status for a message. */
getStatus(messageId: string): Promise<SmsDeliveryReport>;
}
/** Opt-out registry entry. */
export interface OptOutEntry {
/** Phone number that opted out. */
phoneNumber: string;
/** When the opt-out was recorded. */
optedOutAt: Date;
/** Optional channel identifier. */
channel?: string;
}
/** SMS template definition. */
export interface SmsTemplate {
/** Unique template identifier. */
id: string;
/** Template body with {{variable}} placeholders. */
body: string;
/** List of variable names used in the template. */
variables: string[];
/** Optional default values applied when `templateVars` omits a key. */
defaults?: Record<string, string>;
}
/** Retry payload emitted to the bus for observability. */
export interface SmsRetryEvent {
/** Recipient (echo of the message.to). */
to: string;
/** Zero-based retry attempt index. */
attempt: number;
/** Error that triggered the retry. */
error: Error;
}
/** Rate-limit payload emitted to the bus. */
export interface SmsRateLimitedEvent {
/** Recipient that hit the limit. */
to: string;
/** Bucket key the limiter rejected. */
key: string;
}
/** Scheduled-message payload emitted to the bus. */
export interface SmsScheduledEvent {
/** Recipient. */
to: string;
/** Target instant for delivery. */
sendAt: Date;
}
/** Event map for SMS service events. */
export interface SmsEventMap {
"sms.sent": [SmsDeliveryReport];
"sms.delivered": [SmsDeliveryReport];
"sms.failed": [SmsDeliveryReport];
"sms.opted_out": [OptOutEntry];
"sms.opted_in": [{ phoneNumber: string }];
"sms.scheduled": [SmsScheduledEvent];
"sms.retry": [SmsRetryEvent];
"sms.rate_limited": [SmsRateLimitedEvent];
[key: string]: unknown[];
[key: symbol]: unknown[];
}
/** Rate-limiter options. */
export interface RateLimiterOptions {
/** Token-bucket capacity (defaults to 60). */
capacity?: number;
/** Tokens refilled per second (defaults to 1). */
refillPerSecond?: number;
/** Injectable monotonic clock (defaults to `Date.now`). */
clock?: Clock;
}
/** Retry-policy options. */
export interface RetryPolicyOptions {
/** Maximum number of attempts (defaults to 3). */
maxAttempts?: number;
/** Initial delay between attempts in ms (defaults to 50). */
initialDelayMs?: number;
/** Upper bound on backoff delay in ms (defaults to 5000). */
maxDelayMs?: number;
/** Injectable timers seam (defaults to a `globalThis.setTimeout` adapter). */
timers?: TimersLike;
/** Injectable random source for jitter (defaults to `Math.random`). */
random?: () => number;
}
/** Scheduler options. */
export interface MessageSchedulerOptions {
/** Injectable timers seam (defaults to a `globalThis.setTimeout` adapter). */
timers?: TimersLike;
/** Injectable clock (defaults to `Date.now`). */
clock?: Clock;
}
/** Delivery-tracker options. */
export interface DeliveryTrackerOptions {
/** Maximum cached receipts before LRU eviction (defaults to 1024). */
maxEntries?: number;
}
/** Options to construct an `SmsService`. */
export interface SmsServiceOptions {
/** Optional provider to wire eagerly. */
provider?: SmsProvider;
/** Custom ID generator for retry-fallback message IDs. */
idGenerator?: IdGenerator;
/** Rate-limiter configuration. */
rateLimiter?: RateLimiterOptions;
/** Retry-policy configuration. */
retry?: RetryPolicyOptions;
/** Scheduler configuration. */
scheduler?: MessageSchedulerOptions;
/** Delivery-tracker configuration. */
deliveryTracker?: DeliveryTrackerOptions;
}
/** Handle for a scheduled message. */
export interface ScheduleHandle {
/** Cancel the pending schedule; idempotent. */
cancel(): void;
/** Target instant. */
readonly sendAt: Date;
}
/** Scheduled-message envelope. */
export interface ScheduledMessage {
/** Target instant. */
sendAt: Date;
/** Message to deliver. */
message: SmsMessage;
}

View File

@@ -0,0 +1 @@
export { E164_REGEX, isValidE164 } from "./phone.js";

View File

@@ -0,0 +1,70 @@
# `validation/phone`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation/phone` is the strict-E.164 phone number validator
used by every public input boundary in `@suaveplan/sms`. The
validator implements the ITU-T E.164 grammar verbatim — a single
leading `+`, a first digit in `[1-9]`, then one to fourteen
trailing decimal digits — and exposes both the regex constant
and the predicate so callers can compose either against their
own pipelines.
The module is intentionally tiny: a single regex and a single
boolean function. Centralising the rule here keeps the spec a
single source of truth — the service, the Zod schemas, and the
test fixtures all use the same predicate.
## Features
- `E164_REGEX` — exported regex constant for downstream
composition.
- `isValidE164(value)` — boolean predicate, returns `true` only
for syntactically valid E.164 numbers.
- Strict format: rejects bare digits without leading `+`,
rejects `+` alone, rejects `+0…`, rejects letters, rejects
numbers longer than 15 total digits.
## Usage: Basic
```ts
import { isValidE164 } from "@suaveplan/sms/validation/phone";
isValidE164("+15551234567"); // true
isValidE164("15551234567"); // false (no leading +)
isValidE164("+0123456789"); // false (first digit is 0)
```
## Usage: Advanced
```ts
import { E164_REGEX } from "@suaveplan/sms/validation/phone";
import z from "zod";
const PhoneSchema = z
.string()
.regex(E164_REGEX, "must be an E.164 phone number")
.transform((value) => value.toUpperCase());
```
## API Reference
- `const E164_REGEX: RegExp` — `^\+[1-9]\d{1,14}$` (frozen by
TypeScript's `const` semantics).
- `function isValidE164(value: string): boolean` — returns
`E164_REGEX.test(value)`.
## Implementation Notes
The validator does NOT consult a country-code prefix table: it
checks only the syntactic shape required by E.164. Higher-layer
validation (carrier routing, prefix-to-country mapping,
toll-free / premium-rate filters) lives in
`@suaveplan/sms-providers` per-provider adapters because the rule
set varies by carrier. Keeping the syntactic predicate cheap
means a hot path through `SmsService.send` can call
`isValidE164` once per send without measurable overhead.

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { E164_REGEX, isValidE164 } from "./phone.js";
describe("isValidE164", () => {
it("accepts E.164 numbers", { timeout: 5000 }, () => {
expect(isValidE164("+15551234567")).toBe(true);
expect(isValidE164("+447911123456")).toBe(true);
expect(isValidE164("+12")).toBe(true);
expect(isValidE164("+123456789012345")).toBe(true);
});
it("rejects non-E.164 inputs", { timeout: 5000 }, () => {
expect(isValidE164("15551234567")).toBe(false);
expect(isValidE164("+")).toBe(false);
expect(isValidE164("+0123456789")).toBe(false);
expect(isValidE164("+1234567890123456")).toBe(false);
expect(isValidE164("invalid")).toBe(false);
expect(isValidE164("")).toBe(false);
expect(isValidE164("+1abc")).toBe(false);
});
it("exposes the regex constant", { timeout: 5000 }, () => {
expect(E164_REGEX).toBeInstanceOf(RegExp);
expect(E164_REGEX.test("+15551234567")).toBe(true);
});
});

View File

@@ -0,0 +1,14 @@
/**
* E.164 phone-number validation. Strict per ITU-T E.164: a leading `+`,
* a first digit in `[1-9]`, then 1 to 14 trailing decimal digits.
*
* @module validation/phone
*/
/** Strict E.164 regular expression. */
export const E164_REGEX = /^\+[1-9]\d{1,14}$/;
/** Return `true` iff `value` is a syntactically valid E.164 number. */
export function isValidE164(value: string): boolean {
return E164_REGEX.test(value);
}