Initial Commit
This commit is contained in:
183
openspec/archive/2026-05-25-enterprise-sms/design.md
Normal file
183
openspec/archive/2026-05-25-enterprise-sms/design.md
Normal 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.
|
||||
103
openspec/archive/2026-05-25-enterprise-sms/proposal.md
Normal file
103
openspec/archive/2026-05-25-enterprise-sms/proposal.md
Normal 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.
|
||||
436
openspec/archive/2026-05-25-enterprise-sms/specs/sms/spec.md
Normal file
436
openspec/archive/2026-05-25-enterprise-sms/specs/sms/spec.md
Normal 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/"`
|
||||
|
||||
---
|
||||
76
openspec/archive/2026-05-25-enterprise-sms/tasks.md
Normal file
76
openspec/archive/2026-05-25-enterprise-sms/tasks.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user