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,183 @@
# Design — `@suaveplan/sms`
Universal-tier SMS core, provider-agnostic, no Node/Bun-only APIs.
## Module layout
```text
src/
index.ts # root barrel (named re-exports)
error-codes/
error-codes.ts # defineCodes("SMS", [...])
error-codes.test.ts
error-codes.md
index.ts
legacy-errors/
legacy-errors.ts # back-compat SmsError + factories
legacy-errors.test.ts
legacy-errors.md
index.ts
legacy-metadata/
legacy-metadata.ts # SMS_ERROR_METADATA + registerSmsErrorCodes
legacy-metadata.test.ts
legacy-metadata.md
index.ts
schemas/
schemas.ts # Zod for SmsMessage/Template/Provider/etc.
schemas.test.ts
schemas.md
index.ts
types/
sms.types.ts # public type contracts
validation/
phone.ts # isValidE164 helper
phone.test.ts
phone.md
index.ts
templates/
templates.ts # TemplateRegistry + renderTemplate
templates.test.ts
templates.md
index.ts
opt-out/
opt-out.ts # OptOutRegistry
opt-out.test.ts
opt-out.md
index.ts
rate-limiter/
rate-limiter.ts # token-bucket
rate-limiter.test.ts
rate-limiter.md
index.ts
retry/
retry.ts # exponential backoff w/ jitter
retry.test.ts
retry.md
index.ts
scheduler/
scheduler.ts # MessageScheduler (Clock + Timers)
scheduler.test.ts
scheduler.md
index.ts
delivery-tracker/
delivery-tracker.ts # ingest + lookup + cache
delivery-tracker.test.ts
delivery-tracker.md
index.ts
in-memory-provider/
in-memory-provider.ts # InMemorySmsProvider (test prov)
in-memory-provider.test.ts
in-memory-provider.md
index.ts
service/
sms-service.ts # SmsService — orchestrator
sms-service.test.ts
sms-service.md
index.ts
routes/
problem-json.ts # respondProblemJson Hono helper
problem-json.test.ts
problem-json.md
index.ts
otel/
metrics.ts # 4 instruments
metrics.test.ts
metrics.md
index.ts
```
## Public contracts
- Types: `SmsDeliveryStatus`, `SmsMessage`, `SmsDeliveryReport`,
`SmsProvider`, `OptOutEntry`, `SmsTemplate`, `SmsEventMap`,
`SmsServiceOptions`, `ScheduledMessage`, `ScheduleHandle`,
`RetryPolicyOptions`, `RateLimiterOptions`, `DeliveryTrackerOptions`.
- Classes: `SmsService`, `TemplateRegistry`, `OptOutRegistry`,
`RateLimiter`, `RetryPolicy`, `MessageScheduler`,
`DeliveryTracker`, `InMemorySmsProvider`.
- Errors: `SMS_CODES`, `SmsError` (legacy back-compat),
`SmsSendFailedError`, `SmsProviderError`, `SmsInvalidNumberError`,
`SmsRateLimitedError`, `SmsConfigMissingError`,
`SmsForbiddenError`, `SmsTemplateNotFoundError`,
`SmsRecipientOptedOutError`, `SmsProviderNotConfiguredError`,
`SmsScheduleCancelledError`, `SmsAbortedError`,
`SmsInvalidConfigError`.
- Helpers: `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA`, `isValidE164`,
`renderTemplate`.
- OTel: `recordSmsSent`, `recordSmsFailed`, `recordSmsRetry`,
`recordSmsSendDuration`, `refreshSmsMeters`.
## Key behaviours
- **Phone validation** — strict E.164 (`^\+[1-9]\d{1,14}$`).
- **Template render** — `{{var}}` substitution; missing vars → empty
string; supports `defaults` map. Brace literal escape via `{{{`.
- **Opt-out** — set/clear/check, channel-scoped, bulk import/export.
- **Rate limiter** — token bucket per `(channel, recipient)` key.
Configurable capacity + refill-per-second; uses injectable `Clock`.
- **Retry** — exponential backoff with full-jitter; `maxAttempts`,
`initialDelayMs`, `maxDelayMs` configurable; abort-aware.
- **Scheduler** — `sendAt(date, message)` enqueues against an
injectable `Timers`-like seam; returns a `ScheduleHandle` with
`cancel()`. On fire, the message goes through the full pipeline
(validation → rate-limit → retry → provider send → events).
- **Delivery tracker** — `ingestReceipt(report)` records the latest
status keyed by `messageId`; `getStatus(messageId)` returns the
cached entry or `undefined`. Cache size bounded; LRU eviction.
- **Events** — typed `EventBus<SmsEventMap>` on `service.bus`; legacy
`on/off/emit` mirrors against the bus.
- **Abort** — `service.send(msg, { signal })` propagates through the
rate limiter, retry, scheduler timer, and the provider's send call
(when the provider accepts a signal). Aborted operations throw
`SmsAbortedError` with code `SMS.ABORTED`.
## OTel instruments
- Counter `sms.sent_total{channel,from_me}`
- Counter `sms.failed_total{code}`
- Counter `sms.retry_total{attempt}`
- Histogram `sms.send_duration_ms{outcome}`
## Error codes (genesis-only registry)
`defineCodes("SMS", [...])` — 13 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`
The upstream `SMS_*` runtime keys (used by `SMS_ERROR_METADATA` and
the back-compat `SmsError` class — `SMS_SEND_FAILED`,
`SMS_PROVIDER_ERROR`, `SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`,
`SMS_CONFIG_MISSING`, `SMS_FORBIDDEN`, `SMS_INVALID_PHONE_NUMBER`,
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
`SMS_PROVIDER_NOT_CONFIGURED`) are preserved verbatim so legacy
callers keep compiling.
## Subpath exports
Every feature module gets a subpath: `./error-codes`, `./schemas`,
`./service`, `./templates`, `./opt-out`, `./rate-limiter`, `./retry`,
`./scheduler`, `./delivery-tracker`, `./in-memory-provider`,
`./routes/problem-json`, `./otel`, `./types`, `./validation/phone`.
## Tier — universal
The package consumes only:
- `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`,
`@opentelemetry/api`, `tslib`, `zod`.
No Node/Bun-only APIs; uses `globalThis.setTimeout` /
`clearTimeout` (universally available) via injectable `Timers` seam.

View File

@@ -0,0 +1,103 @@
# Enterprise migration: `@suaveplan/sms`
- **Change ID**: `enterprise-sms`
- **Status**: PROPOSED
- **Capability**: `sms`
- **Target package**: `@suaveplan/sms` at `0.1.0`
- **Target path**: `packages/communication/messaging/sms/`
- **Design**: ./design.md
- **Spec delta**: ./specs/sms/spec.md
- **Source on main**: `/mnt/s/web/suaveplan/packages/server/sms` (`~1389 LOC`)
- **Enterprise plan**: `/mnt/s/web/suaveplan/enterprise/sms.md`
## Why
`@suaveplan/sms` is the universal SMS core: a provider-agnostic
sending pipeline with template rendering, opt-out management, rate
limiting, scheduling, retry, and delivery tracking. Providers
(Twilio, Vonage, MessageBird, AWS SNS, in-house gateways) plug into
the abstract `SmsProvider` contract.
The upstream package (`v1.0.2`) is functionally narrow — provider
seam, template render, opt-out list, four events — but is missing
every enterprise lever the genesis tree commits to:
- `BaseError` legacy hierarchy instead of `defineCodes("SMS", [...])`.
- No Zod boundary on `SmsMessage`, `SmsTemplate`, `SmsProvider` inputs.
- No OpenTelemetry counters or histograms.
- No retry/backoff on transient provider failures.
- No scheduling (`sendAt`) or batched dispatch.
- No rate limiting per provider / per recipient.
- No delivery-receipt ingest pipeline; status is fetched on demand.
- No `AbortSignal` propagation on send / scheduler / status pulls.
- No in-memory test provider; tests use ad-hoc anonymous mocks.
- One global event-emitter shape; no typed `EventBus` bridge.
- Tests pass with vi-mocks (`@suaveplan/testing` `vi.fn()`) — fine for
unit harnesses, but the package never exercises a real provider
round-trip end-to-end.
This change rewrites the package on genesis at enterprise quality
while preserving every upstream export, then layers the SMS-core
features (`MessageScheduler`, `RateLimiter`, `DeliveryTracker`,
`RetryPolicy`, OTel instruments, Zod boundaries, abort signals,
`EventBus` bridge, in-memory test provider).
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `SmsService` send / template render / opt-out | ✅ | ✅ | Preserved verbatim, plus rate-limit + abort + retry on top. |
| `SmsProvider` abstract contract | ✅ | ✅ | Preserved (`name`, `send`, `getStatus`). |
| `SmsTemplate` (`{{var}}` substitution) | ✅ | ✅ | Preserved + `defaults` map + escape pass. |
| `SmsMessage` / `SmsDeliveryReport` / `SmsDeliveryStatus` | ✅ | ✅ | Preserved + Zod schemas. |
| `OptOutEntry` registry | ✅ | ✅ | Preserved + bulk import / export. |
| `SmsEventMap` (`sms.sent`/`delivered`/`failed`/`opted_out`/`opted_in`) | ✅ | ✅ | Preserved + new `sms.scheduled` / `sms.retry` / `sms.rate_limited`. |
| Legacy `EventEmitter` surface (`on/off/emit`) | ✅ | ✅ | Preserved (delegates to typed `EventBus`). |
| `SmsError` legacy class + factory methods | ✅ | ✅ | Preserved as back-compat alias over genesis `defineCodes` family. |
| `respondProblemJson` Hono helper | ✅ | ✅ | Preserved. |
| `registerSmsErrorCodes` / `SMS_ERROR_METADATA` | ✅ | ✅ | Preserved via genesis `ErrorCodes.registerMany`. |
| Stable `SMS.*` `defineCodes` registry | 🚫 (legacy `BaseError`) | ⬆️ | Genesis addition. |
| Zod boundary on every public input | 🚫 | ⬆️ | Genesis addition. |
| OTel counters + histogram | 🚫 | ⬆️ | Genesis addition (4 instruments). |
| `MessageScheduler` (sendAt + cancel) | 🚫 | ⬆️ | Genesis addition. |
| `RateLimiter` (token bucket per recipient) | 🚫 | ⬆️ | Genesis addition. |
| `DeliveryTracker` (receipt ingest + cache) | 🚫 | ⬆️ | Genesis addition. |
| `RetryPolicy` (exponential backoff w/ jitter) | 🚫 | ⬆️ | Genesis addition. |
| `InMemorySmsProvider` (test provider) | 🚫 | ⬆️ | Genesis addition; replaces ad-hoc test mocks. |
| `AbortSignal` propagation | 🚫 | ⬆️ | Genesis addition. |
| 100/100/100/100 coverage | 🚫 | ⬆️ | Genesis gate. |
## What
A net-new package at `packages/communication/messaging/sms/` at
`0.1.0`, universal tier (no Node/Bun-only APIs), Zod-validated
boundaries, OTel-instrumented, with the full upstream public
surface plus the genesis enterprise additions.
This is the SMS **core** — not the providers package. The abstract
`SmsProvider` interface ships with an in-memory test provider
(`InMemorySmsProvider`) so consumers and integration tests can run
without external services. Concrete provider integrations
(Twilio, Vonage, AWS SNS) ship in the sibling `@suaveplan/sms-providers`
package next wave.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/sms` to enumerate.
No genesis package currently imports `@suaveplan/sms`:
- `@suaveplan/sms-providers` — pending migration, will consume this as `workspace:^0.1.0`. [x] no action needed (reason: package not yet on genesis)
- `@suaveplan/api-plugin-messaging` — pending migration. [x] no action needed (reason: package not yet on genesis)
Foundation deps (`@suaveplan/error`, `@suaveplan/events`,
`@suaveplan/types`) are read-only consumers; no change required.
## Out of scope
Nothing. Per `feedback_no_deferrals_on_migration.md` and AGENTS §2b,
no "phase 2" is permitted. The whole upstream surface plus the
enterprise additions land here, feature-complete, with
100/100/100/100 coverage.

View File

@@ -0,0 +1,436 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: sms (sms)
**Package**: `@suaveplan/sms`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Public package surface
The package SHALL export, from its root barrel `src/index.ts`, the
full upstream surface: `SmsService`, the `SmsProvider` interface,
`SmsMessage`, `SmsDeliveryReport`, `SmsDeliveryStatus`,
`OptOutEntry`, `SmsTemplate`, `SmsEventMap`, the legacy `SmsError`
class with its factory methods, `SmsErrorCode`, `SmsErrorContext`,
`SmsErrorOptions`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA`, and
`SmsRegisteredErrorCode`. It SHALL additionally export the genesis
additions: `SMS_CODES`, every leaf error class, every Zod schema,
`TemplateRegistry`, `OptOutRegistry`, `RateLimiter`, `RetryPolicy`,
`MessageScheduler`, `DeliveryTracker`, `InMemorySmsProvider`,
`renderTemplate`, `isValidE164`, and the OTel `record*` helpers.
#### Scenario: every upstream symbol is re-exported
- **WHEN** a consumer imports from `@suaveplan/sms`
- **THEN** `SmsService`, `SmsProvider` (type), `SmsMessage` (type),
`SmsDeliveryReport` (type), `SmsDeliveryStatus` (type),
`OptOutEntry` (type), `SmsTemplate` (type), `SmsEventMap` (type),
`SmsError`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA` are all defined
**Verified by:** `src/service/sms-service.test.ts::"re-exports the full upstream surface"`
---
### Requirement: Stable SMS error-code registry
The package SHALL register exactly thirteen error codes via
`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`. Every code SHALL be reachable from at least
one production call site and SHALL be asserted by a test that
triggers the real call site.
#### Scenario: every code is reachable from a production call site
- **WHEN** the package is fully built
- **THEN** for each of the thirteen codes there exists at least one
test that triggers the production call site whose `throw` (or
caught error) carries that code
**Verified by:** `src/error-codes/error-codes.test.ts::"every SMS code is reachable from a production call site"`
---
### Requirement: Legacy SmsError back-compat
The package SHALL preserve the upstream `SmsError` class with the
exact `SmsErrorCode` union (`SMS_INVALID_PHONE_NUMBER`,
`SMS_SEND_FAILED`, `SMS_TEMPLATE_NOT_FOUND`,
`SMS_RECIPIENT_OPTED_OUT`, `SMS_PROVIDER_NOT_CONFIGURED`) and the
five static factory methods (`invalidPhoneNumber`, `sendFailed`,
`templateNotFound`, `recipientOptedOut`, `providerNotConfigured`)
plus the `isSmsError` type guard. The legacy class SHALL also carry
the same `SmsErrorContext` and `SmsErrorOptions` shapes.
#### Scenario: legacy factories return SmsError instances
- **WHEN** `SmsError.invalidPhoneNumber("xx")` is called
- **THEN** the returned value is `instanceof SmsError`, `code` is
`"SMS_INVALID_PHONE_NUMBER"`, and `SmsError.isSmsError(value)` is
`true`
**Verified by:** `src/legacy-errors/legacy-errors.test.ts::"factory methods return the right code"` AND `src/legacy-errors/legacy-errors.test.ts::"isSmsError narrows the type"`
---
### Requirement: Legacy metadata registration
The package SHALL ship `SMS_ERROR_METADATA` containing the six
upstream metadata entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
`SMS_FORBIDDEN`) with the same `httpStatus` / `severity` / `retry` /
`userMessageKey` / `category` / `description` fields, and SHALL
register every entry on the shared `ErrorCodes` singleton via
`registerSmsErrorCodes()` at module load time. The function SHALL
be idempotent.
#### Scenario: SMS_RATE_LIMITED maps to HTTP 429
- **WHEN** `SMS_ERROR_METADATA.SMS_RATE_LIMITED` is read after
`registerSmsErrorCodes()` has run
- **THEN** the entry has `httpStatus === 429`, `severity ===
"warning"`, and `retry.retryable === true`
**Verified by:** `src/legacy-metadata/legacy-metadata.test.ts::"every metadata entry preserves the upstream shape"`
---
### Requirement: E.164 phone validation
`isValidE164(value)` SHALL return `true` only when `value` matches
`/^\+[1-9]\d{1,14}$/`. The service SHALL reject every `send()` call
whose `message.to` fails this check by throwing
`SmsInvalidNumberError` (code `SMS.INVALID_NUMBER`).
#### Scenario: valid +15551234567 passes
- **WHEN** `isValidE164("+15551234567")` is called
- **THEN** the return value is `true`
#### Scenario: missing leading + is rejected
- **WHEN** `isValidE164("15551234567")` is called
- **THEN** the return value is `false`
#### Scenario: leading +0 is rejected
- **WHEN** `isValidE164("+0123456789")` is called
- **THEN** the return value is `false`
**Verified by:** `src/validation/phone.test.ts::"accepts E.164 numbers"` AND `src/validation/phone.test.ts::"rejects non-E.164 inputs"`
---
### Requirement: Zod boundary on public inputs
`schemas/schemas.ts` SHALL export Zod schemas for `SmsMessage`,
`SmsTemplate`, `SmsDeliveryReport`, and `SmsServiceOptions`. The
parser helpers `parseSmsMessage` and `parseSmsTemplate` SHALL throw
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with
`context.issues` containing the Zod issues on rejection.
#### Scenario: empty body is rejected
- **WHEN** `parseSmsMessage({ to: "+15551234567", body: "" })` is
called
- **THEN** the call throws `SmsInvalidConfigError` whose `code` is
`SMS.INVALID_CONFIG` and whose `context.issues` is a non-empty
array
**Verified by:** `src/schemas/schemas.test.ts::"rejects empty body"` AND `src/schemas/schemas.test.ts::"rejects non-E.164 to"`
---
### Requirement: Template registry and render
`TemplateRegistry` SHALL provide `register(template)`,
`get(id)`, `has(id)`, `unregister(id)`, and `list()`. `renderTemplate(template, vars, defaults?)` SHALL replace every `{{var}}` placeholder declared in `template.variables` with `vars[var] ?? defaults?.[var] ?? ""`.
#### Scenario: rendering substitutes declared variables
- **WHEN** `renderTemplate({ id: "x", body: "Hello {{name}}", variables: ["name"] }, { name: "Alice" })` is called
- **THEN** the returned string is `"Hello Alice"`
#### Scenario: missing variables default to empty string
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {})` is called
- **THEN** the returned string is `"Hi "`
#### Scenario: defaults map fills missing variables
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {}, { name: "World" })` is called
- **THEN** the returned string is `"Hi World"`
**Verified by:** `src/templates/templates.test.ts::"substitutes declared variables"` AND `src/templates/templates.test.ts::"missing variables fall back to defaults then empty string"` AND `src/templates/templates.test.ts::"registry register/get/has/unregister/list"`
---
### Requirement: Opt-out registry
`OptOutRegistry` SHALL provide `add(phoneNumber, channel?)`,
`remove(phoneNumber)`, `has(phoneNumber)`, `list()`,
`importEntries(entries)`, and `clear()`. `SmsService` SHALL reject
every `send()` to an opted-out recipient with
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
#### Scenario: opt-out blocks send
- **WHEN** a recipient is added via `optOut(phone)` and `service.send({to: phone, ...})` is called
- **THEN** the send throws `SmsRecipientOptedOutError`
#### Scenario: opt-in clears the registry entry
- **WHEN** `optIn(phone)` is called for an opted-out recipient
- **THEN** `isOptedOut(phone)` returns `false`
**Verified by:** `src/opt-out/opt-out.test.ts::"add/remove/has/list/clear"` AND `src/service/sms-service.test.ts::"opt-out blocks send"` AND `src/service/sms-service.test.ts::"opt-in unblocks send"`
---
### Requirement: Rate limiter
`RateLimiter` SHALL implement a token bucket with `capacity` and
`refillPerSecond`, keyed by an arbitrary string key. `tryConsume(key)` SHALL return `true` when a token is available and `false` otherwise, refilling tokens against an injectable `Clock`. `SmsService` SHALL throw `SmsRateLimitedError` (code `SMS.RATE_LIMITED`) when the limiter rejects.
#### Scenario: bucket exhausts after capacity tokens
- **WHEN** `tryConsume("k")` is called `capacity + 1` times in a row without time advancing
- **THEN** the first `capacity` calls return `true` and the last returns `false`
#### Scenario: time advances refill tokens
- **WHEN** the bucket is exhausted and the injected clock is advanced by `1000ms` with `refillPerSecond = 1`
- **THEN** the next `tryConsume("k")` returns `true`
**Verified by:** `src/rate-limiter/rate-limiter.test.ts::"exhausts after capacity"` AND `src/rate-limiter/rate-limiter.test.ts::"refills on clock advance"` AND `src/service/sms-service.test.ts::"rate-limited recipient throws SmsRateLimitedError"`
---
### Requirement: Retry policy
`RetryPolicy` SHALL retry a thrown attempt up to
`maxAttempts` times with exponential backoff
(`initialDelayMs * 2^(attempt - 1)`, capped at `maxDelayMs`) and
full jitter. Each retry SHALL be awaited against an injectable
`Timers.delay(ms, signal)`. An aborted retry SHALL throw
`SmsAbortedError` (code `SMS.ABORTED`).
#### Scenario: succeeds on second attempt
- **WHEN** a callback fails once then succeeds
- **THEN** the policy returns the success value and emits one retry event
#### Scenario: exhausts attempts and rethrows
- **WHEN** a callback fails on every attempt
- **THEN** the policy rethrows the last error after `maxAttempts` invocations
#### Scenario: abort signal halts retries
- **WHEN** the signal aborts between attempts
- **THEN** the policy throws `SmsAbortedError`
**Verified by:** `src/retry/retry.test.ts::"succeeds on second attempt"` AND `src/retry/retry.test.ts::"exhausts attempts"` AND `src/retry/retry.test.ts::"abort halts retries"`
---
### Requirement: Message scheduler
`MessageScheduler.scheduleAt(date, fn)` SHALL invoke `fn` at the
target time through an injectable `Timers.setTimeout`-like seam and
return a `ScheduleHandle` with `cancel()`. A cancelled handle SHALL
not invoke `fn`. `SmsService.scheduleAt(date, message)` SHALL emit
`sms.scheduled` on the bus when the message is queued, and route
the eventual send through the full pipeline. A cancelled schedule
SHALL emit `sms.failed` with code `SMS.SCHEDULE_CANCELLED`.
#### Scenario: scheduled message fires at the target time
- **WHEN** a message is scheduled for `now + 100ms` and the injected timer fires
- **THEN** the provider's `send` is invoked exactly once
#### Scenario: cancelled schedule does not fire
- **WHEN** `cancel()` is called before the timer fires
- **THEN** the provider's `send` is not invoked
**Verified by:** `src/scheduler/scheduler.test.ts::"fires at the target time"` AND `src/scheduler/scheduler.test.ts::"cancel prevents firing"` AND `src/service/sms-service.test.ts::"scheduleAt routes through the pipeline"`
---
### Requirement: Delivery tracker
`DeliveryTracker` SHALL accept incoming delivery receipts via
`ingest(report)` and return the latest cached status via
`getStatus(messageId)`. The cache SHALL be bounded by an LRU policy
with configurable `maxEntries` (default `1024`). `lookupOrFetch(messageId, provider)` SHALL fall back to the provider's `getStatus`
when no cached entry exists, caching the result. Cache miss with no
provider SHALL throw `SmsReceiptNotFoundError` (code
`SMS.RECEIPT_NOT_FOUND`).
#### Scenario: ingested receipt is retrievable
- **WHEN** a report with `messageId: "m1"` is ingested then
`getStatus("m1")` is called
- **THEN** the returned report deep-equals the ingested report
#### Scenario: LRU eviction drops the oldest entry
- **WHEN** `maxEntries = 2` and three reports `m1`, `m2`, `m3` are
ingested in order
- **THEN** `getStatus("m1")` returns `undefined`
**Verified by:** `src/delivery-tracker/delivery-tracker.test.ts::"ingest/getStatus round-trip"` AND `src/delivery-tracker/delivery-tracker.test.ts::"LRU eviction"` AND `src/delivery-tracker/delivery-tracker.test.ts::"lookupOrFetch falls back to provider"` AND `src/delivery-tracker/delivery-tracker.test.ts::"miss without provider throws receipt-not-found"`
---
### Requirement: In-memory provider
`InMemorySmsProvider` SHALL implement `SmsProvider` with `name` =
`"in-memory"`, deliver every message synchronously into a `sent`
log, and return a `SmsDeliveryReport` with `status` controlled by
the constructor's `defaultStatus` (default `"sent"`). It SHALL
expose `sent`, `clear()`, `setStatus(messageId, status)`, and
`failOn(predicate, error)` so tests can simulate provider failures
and status transitions without external services.
#### Scenario: send appends to the sent log
- **WHEN** `provider.send({ to: "+15551234567", body: "x" })` is
called
- **THEN** `provider.sent` has length 1 and the entry's `to` is
`"+15551234567"`
#### Scenario: failOn triggers a thrown error
- **WHEN** `provider.failOn(msg => msg.to === "+15550000000", new Error("boom"))` is set and `send({to: "+15550000000", body: "x"})` is called
- **THEN** the call rejects with the configured error
**Verified by:** `src/in-memory-provider/in-memory-provider.test.ts::"send appends to log"` AND `src/in-memory-provider/in-memory-provider.test.ts::"failOn triggers"` AND `src/in-memory-provider/in-memory-provider.test.ts::"setStatus transitions delivery state"`
---
### Requirement: OpenTelemetry instruments
The package SHALL register four OTel instruments on the
`@suaveplan/sms` meter: counters `sms.sent_total`,
`sms.failed_total`, `sms.retry_total`, and histogram
`sms.send_duration_ms`. The module SHALL expose `record*` helpers
and a `refreshSmsMeters()` re-bind for tests.
#### Scenario: successful send records sent_total + send_duration_ms
- **WHEN** `service.send(validMessage)` is called against an
in-memory provider
- **THEN** an in-memory metric exporter reads at least one data
point on `sms.sent_total` and one on `sms.send_duration_ms`
**Verified by:** `src/otel/metrics.test.ts::"refresh re-binds against the global provider"` AND `src/service/sms-service.test.ts::"send records sent_total and send_duration_ms"`
---
### Requirement: Typed EventBus + legacy on/off mirror
`SmsService` SHALL expose a typed `EventBus<SmsEventMap>` field
named `bus`, AND a legacy `on/off/emit` surface that mirrors against
the bus. Topics: `sms.sent`, `sms.delivered`, `sms.failed`,
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
`sms.rate_limited`.
#### Scenario: legacy on/off mirrors the bus
- **WHEN** a listener is registered via `service.on("sms.sent", h)`
and the service emits `sms.sent`
- **THEN** the handler is invoked exactly once with the report
#### Scenario: bus.on receives the same payload
- **WHEN** `service.bus.on("sms.sent", h)` is registered before a
send
- **THEN** the bus handler is invoked with the same report payload
**Verified by:** `src/service/sms-service.test.ts::"legacy on/off mirrors the bus"` AND `src/service/sms-service.test.ts::"bus receives emitted reports"`
---
### Requirement: Abort signal propagation
`SmsService.send(message, { signal })` SHALL propagate the abort
signal through the rate limiter, retry policy, and provider. An
abort BEFORE the provider call SHALL throw `SmsAbortedError`. An
abort that lands inside a retry delay SHALL throw `SmsAbortedError`
without invoking the next attempt.
#### Scenario: pre-aborted signal halts send immediately
- **WHEN** `service.send(msg, { signal: AbortSignal.abort() })` is
called
- **THEN** the call throws `SmsAbortedError` (code `SMS.ABORTED`)
and the provider's `send` is not invoked
**Verified by:** `src/service/sms-service.test.ts::"pre-aborted signal halts send"` AND `src/retry/retry.test.ts::"abort halts retries"`
---
### Requirement: Hono problem+json helper
`respondProblemJson(c, err)` SHALL serialise any `BaseError` /
`HttpError` / `SuaveplanError` / unknown throwable into an RFC 7807
`application/problem+json` response on the Hono `Context`-like
surface. The helper SHALL preserve the `httpStatus` registered by
`SMS_ERROR_METADATA` when the error's code is registered in the
shared `ErrorCodes` singleton, and SHALL fall back to `500` for
unknown errors.
#### Scenario: registered SMS code preserves its httpStatus
- **WHEN** `respondProblemJson(c, new SmsError({code: "SMS_RATE_LIMITED", message: "x"}))` is called
- **THEN** the response body's `status` field is `429`
**Verified by:** `src/routes/problem-json.test.ts::"registered code preserves httpStatus"` AND `src/routes/problem-json.test.ts::"unknown error falls back to 500"`
---
### Requirement: Subpath exports per feature
The `package.json` `exports` map SHALL expose a tree-shakeable
subpath for every feature module: `./error-codes`,
`./legacy-errors`, `./legacy-metadata`, `./schemas`,
`./validation/phone`, `./templates`, `./opt-out`,
`./rate-limiter`, `./retry`, `./scheduler`,
`./delivery-tracker`, `./in-memory-provider`, `./service`,
`./routes/problem-json`, `./otel`.
#### Scenario: feature subpaths resolve
- **WHEN** the package is fully built
- **THEN** `Object.keys(pkg.exports).filter(k => k.startsWith("./")).length >= 15`
**Verified by:** `src/service/sms-service.test.ts::"feature subpaths are declared in package.json"`
---
### Requirement: Universal-tier purity
The package SHALL not import any Node-only or Bun-only API
(`node:*`, `bun:*`). It SHALL rely only on `globalThis.setTimeout`
/ `clearTimeout` (universally available) via an injectable
`Timers` seam.
#### Scenario: no node: / bun: imports in src/
- **WHEN** the repository is grepped for `from ['\"]node:|from ['\"]bun:`
under `packages/communication/messaging/sms/src/`
- **THEN** the grep yields zero matches
**Verified by:** `src/service/sms-service.test.ts::"no node: or bun: imports leak into src/"`
---

View File

@@ -0,0 +1,76 @@
# Tasks — `@suaveplan/sms` enterprise migration
## 1. Package scaffold
- [ ] Create `packages/communication/messaging/sms/` with `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `README.md`.
- [ ] Add deps: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `@opentelemetry/api`, `tslib`, `zod`; devdeps: `@suaveplan/testing`, `@opentelemetry/sdk-metrics`, `@vitest/coverage-istanbul`, `vitest`, `vite`, `vite-plugin-dts`, `typescript`.
- [ ] Subpath exports for every feature module + `./types`, `./routes/problem-json`.
## 2. Error codes
- [ ] `error-codes/error-codes.ts` — `defineCodes("SMS", [...])` with 13 codes, typed `SmsError` base, 12 leaf classes.
- [ ] `error-codes/error-codes.test.ts` — registry sort assertion + reachability test (every code raised from a real call site).
- [ ] `error-codes/error-codes.md`.
## 3. Legacy compatibility
- [ ] `legacy-errors/legacy-errors.ts` — back-compat `SmsError` class with the upstream `SmsErrorCode` union + factories (`invalidPhoneNumber`, `sendFailed`, `templateNotFound`, `recipientOptedOut`, `providerNotConfigured`).
- [ ] `legacy-errors/legacy-errors.test.ts`.
- [ ] `legacy-metadata/legacy-metadata.ts` — `SMS_ERROR_METADATA` + `registerSmsErrorCodes()` + `SmsRegisteredErrorCode`; eager-register on import.
- [ ] `legacy-metadata/legacy-metadata.test.ts`.
## 4. Types + schemas
- [ ] `types/sms.types.ts` — `SmsDeliveryStatus`, `SmsMessage`, `SmsDeliveryReport`, `SmsProvider`, `OptOutEntry`, `SmsTemplate`, `SmsEventMap`, `SmsServiceOptions`, `ScheduledMessage`, `ScheduleHandle`.
- [ ] `schemas/schemas.ts` — Zod for `SmsMessage`, `SmsTemplate`, `SmsDeliveryReport`, `SmsServiceOptions`; `parseSmsMessage` / `parseSmsTemplate` throw `SmsInvalidConfigError`.
## 5. Pipeline primitives
- [ ] `validation/phone.ts` — `isValidE164`.
- [ ] `templates/templates.ts` — `TemplateRegistry`, `renderTemplate(template, vars, defaults?)`.
- [ ] `opt-out/opt-out.ts` — `OptOutRegistry` (set/clear/check/bulk).
- [ ] `rate-limiter/rate-limiter.ts` — `RateLimiter` token-bucket with injectable `Clock`.
- [ ] `retry/retry.ts` — `RetryPolicy` exponential w/ jitter, abort-aware.
- [ ] `scheduler/scheduler.ts` — `MessageScheduler` with injectable `Timers`.
- [ ] `delivery-tracker/delivery-tracker.ts` — bounded LRU receipt cache.
## 6. In-memory provider
- [ ] `in-memory-provider/in-memory-provider.ts` — `InMemorySmsProvider` implementing `SmsProvider`; records sent messages, configurable per-send outcome (queued/sent/delivered/failed/undelivered), supports `simulateFailure`, supports `advance` for delivery state transitions.
## 7. OTel
- [ ] `otel/metrics.ts` — counters `sms.sent_total`, `sms.failed_total`, `sms.retry_total`; histogram `sms.send_duration_ms`; `refreshSmsMeters()`.
## 8. Service orchestrator
- [ ] `service/sms-service.ts` — `SmsService` extends/wraps `EventBus<SmsEventMap>`; constructor accepts `SmsServiceOptions` (Zod-validated); legacy `on/off/emit` mirrors against the bus; integrates rate-limiter + retry + scheduler + delivery-tracker; honours `AbortSignal`.
- [ ] `service/sms-service.test.ts` — full pipeline tests against `InMemorySmsProvider`.
## 9. Hono helper
- [ ] `routes/problem-json.ts` — `respondProblemJson` + `PROBLEM_JSON_CONTENT_TYPE`, mapping `SuaveplanError` / `HttpError` / unknown → problem+json.
## 10. Index barrel + sibling docs
- [ ] `src/index.ts` — explicit named re-exports of every public symbol.
- [ ] Sibling `.md` per `.ts` (≥200 words, 6 sections).
- [ ] Package-root `README.md` with quick start, modules table, install.
## 11. Verification
- [ ] `bunx turbo lint typecheck test build --filter=@suaveplan/sms` green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/sms` ≥ 100/100/100/100.
- [ ] Banned-pattern scan zero output (`TODO`, `FIXME`, `ts-ignore`, `.skip`, `.only`, `any`, `biome-ignore`).
- [ ] Test-runner-import scan zero output (`from "vitest"|"bun:test"`).
- [ ] All sibling `.md` present; `README.md` present.
## 12. Manifest + STATUS
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
- [ ] STATUS.md: flip `@suaveplan/sms` `–` → `✅`, decrement Pending, increment Migrated.
## 13. Spec finalisation
- [ ] Fold `specs/sms/spec.md` into `openspec/specs/sms/spec.md`.
- [ ] Move change folder to `openspec/archive/2026-05-25-enterprise-sms/`.