Initial Commit
This commit is contained in:
183
openspec/archive/2026-05-17-add-currency/design.md
Normal file
183
openspec/archive/2026-05-17-add-currency/design.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Design — @suaveplan/currency
|
||||
|
||||
## Architecture
|
||||
|
||||
`@suaveplan/currency` is a universal-tier orchestrator. It owns no rate data; it
|
||||
owns the contract every rate source implements (`CurrencyProvider`), the
|
||||
in-memory TTL cache that flattens repeated calls into a single provider hit
|
||||
(`RateCache`), and the converter that combines them with cross-rate
|
||||
triangulation and a fallback chain (`CurrencyConverter`). Every public boundary
|
||||
is Zod-validated; every failure path raises a typed `CurrencyError`. The package
|
||||
runs unchanged under Node, Bun, browsers, and Web Workers.
|
||||
|
||||
```text
|
||||
┌──────────────────────────┐
|
||||
│ CurrencyConverter │
|
||||
│ │
|
||||
convert(money) ───▶│ validate options (Zod) │
|
||||
│ walk fallback chain │
|
||||
│ fetch → cache → seed │
|
||||
│ triangulate via cache │
|
||||
│ enrich result │
|
||||
│ honour AbortSignal │
|
||||
└──┬────────────────┬──────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌──────────────────┐
|
||||
│ RateCache │ │ CurrencyProvider │
|
||||
│ (TTL) │ │ (primary + │
|
||||
│ prune / │ │ fallbacks) │
|
||||
│ triangulate│ └────────┬─────────┘
|
||||
└─────────────┘ │
|
||||
├─ InMemoryProvider
|
||||
├─ createStaticProvider(...)
|
||||
└─ (future external providers)
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
Every feature lives at `src/<feature>/<feature>.ts` per the no-prefix rule;
|
||||
every feature has a sibling `<feature>.md`, `<feature>.test.ts`, and an
|
||||
`index.ts` barrel using explicit named re-exports.
|
||||
|
||||
```text
|
||||
packages/finance/currency/
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── tsconfig.json
|
||||
├── tsconfig.lib.json
|
||||
├── vite.config.ts
|
||||
├── vitest.config.ts
|
||||
├── tests/
|
||||
│ └── setup/
|
||||
│ └── polyfills.ts
|
||||
└── src/
|
||||
├── index.ts # root barrel — namespace + named re-exports
|
||||
├── types/
|
||||
│ ├── index.ts
|
||||
│ ├── common.ts # ExchangeRate, RateFetchOptions, RateFetchResult,
|
||||
│ │ # CurrencyProvider, VirtualCurrencyConfig,
|
||||
│ │ # ConverterConfig, ConvertOptions, ConversionResult
|
||||
│ └── common.md
|
||||
├── error-codes/
|
||||
│ ├── index.ts
|
||||
│ ├── error-codes.ts # CURRENCY_CODES + CurrencyError
|
||||
│ ├── error-codes.md
|
||||
│ └── error-codes.test.ts
|
||||
├── schemas/
|
||||
│ ├── index.ts
|
||||
│ ├── schemas.ts # Zod schemas + parse helpers raising CurrencyError
|
||||
│ ├── schemas.md
|
||||
│ └── schemas.test.ts
|
||||
├── rate-cache/
|
||||
│ ├── index.ts
|
||||
│ ├── rate-cache.ts # RateCache class
|
||||
│ ├── rate-cache.md
|
||||
│ └── rate-cache.test.ts
|
||||
├── provider/
|
||||
│ ├── index.ts
|
||||
│ ├── provider.ts # InMemoryProvider + createStaticProvider
|
||||
│ ├── provider.md
|
||||
│ └── provider.test.ts
|
||||
└── converter/
|
||||
├── index.ts
|
||||
├── converter.ts # CurrencyConverter class
|
||||
├── converter.md
|
||||
└── converter.test.ts
|
||||
```
|
||||
|
||||
## Public surface
|
||||
|
||||
Re-exported from the package root (`@suaveplan/currency`):
|
||||
|
||||
- Classes: `CurrencyConverter`, `RateCache`, `InMemoryProvider`, `CurrencyError`.
|
||||
- Factories: `createStaticProvider`.
|
||||
- Constants: `CURRENCY_CODES`.
|
||||
- Schemas: `ConverterConfigSchema`, `ConvertOptionsSchema`,
|
||||
`VirtualCurrencyConfigSchema`, `RateFetchOptionsSchema`.
|
||||
- Types: `ConversionResult`, `ConverterConfig`, `ConvertOptions`,
|
||||
`CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`,
|
||||
`VirtualCurrencyConfig`, `CurrencyErrorCode`, `CurrencyErrorOptions`.
|
||||
|
||||
Every public module is also reachable via a subpath export
|
||||
(`@suaveplan/currency/converter/converter` etc.) so consumers can tree-shake.
|
||||
|
||||
## Behavioural notes
|
||||
|
||||
### Triangulation
|
||||
|
||||
The migration preserves the upstream single-hop-via-cache triangulation. When
|
||||
`getRate(from, to)` is called and the cache has neither a direct nor an inverse
|
||||
entry, `RateCache.getRate` walks every cached base entry looking for one whose
|
||||
`rates` map contains both `from` and `to`. If found, the converter records the
|
||||
intermediary base in `ConversionResult.triangulatedThrough = [base]`. If not
|
||||
found, the converter fetches fresh data from the primary provider against
|
||||
`from`; if that fails, it falls back to the chain; if still nothing produces a
|
||||
direct rate, it raises `CurrencyError({ code: CURRENCY.RATE_UNAVAILABLE })`.
|
||||
|
||||
Multi-hop BFS is explicitly out of scope (see proposal `## Out of scope`). The
|
||||
`triangulatedThrough` field returns `string[]` to leave room for the future
|
||||
multi-hop change without breaking the type.
|
||||
|
||||
### Fallback chain & cancellation
|
||||
|
||||
Providers are tried in order: primary then `fallbackProviders`. The first one
|
||||
whose `fetchRates` resolves wins; its result is set in the cache and virtual
|
||||
rates are re-seeded. If `options.signal` is supplied and aborts between
|
||||
provider attempts, the chain stops immediately and the converter raises
|
||||
`CurrencyError({ code: CURRENCY.ABORTED })`. If every provider fails the chain
|
||||
raises `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE })` whose `cause`
|
||||
carries the last underlying error and whose `providerName` discriminant is the
|
||||
last attempted provider.
|
||||
|
||||
### Virtual currency seeding
|
||||
|
||||
`registerVirtualCurrencies` is called from the constructor when
|
||||
`config.virtualCurrencies` is provided. Each entry is registered through
|
||||
`@suaveplan/money`'s `registerCurrency` (so `Money.fromMajor("100", "GOLD")`
|
||||
works for callers), and its rate against `defaultBase` is added to the
|
||||
`virtualRates` private map. `seedVirtualRates` writes those rates into the
|
||||
cache entry keyed by `defaultBase`. After every successful provider fetch the
|
||||
seeding runs again so virtual rates survive provider refreshes.
|
||||
`setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` mutate the
|
||||
private map and call `seedVirtualRates`; the two are aliases — the audit
|
||||
mentions both names.
|
||||
|
||||
### `ConversionResult.providerName`
|
||||
|
||||
- `"<cache>"` — the rate was served from the cache (no provider call).
|
||||
- `"<virtual>"` — the rate was seeded by the virtual-currency configuration.
|
||||
- `"<identity>"` — `from === to` short-circuit.
|
||||
- otherwise — the `name` of the provider whose `fetchRates` produced the entry
|
||||
the cache resolved against.
|
||||
|
||||
### Zod boundary
|
||||
|
||||
`ConverterConfigSchema` validates the constructor argument. `ConvertOptionsSchema`,
|
||||
`RateFetchOptionsSchema`, and `VirtualCurrencyConfigSchema` validate every other
|
||||
public entry point. Failures raise `CurrencyError` with the appropriate code
|
||||
(`INVALID_CONFIG` for the constructor; `INVALID_OPTIONS` for everything else)
|
||||
and the Zod issue list on `error.context.issues`.
|
||||
|
||||
### Strict typing
|
||||
|
||||
`CurrencyProvider` and every option bundle is a top-level named interface (per
|
||||
AGENTS.md §11 — no inline shapes). `CurrencyConverter` is generic-free; future
|
||||
markup-policy work can graft generics on without breaking the current surface.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@suaveplan/money` (`workspace:^`) — currency metadata, `MoneyLike`,
|
||||
`minorUnitMultiplier`, `registerCurrency`, `getCurrencyOrThrow`.
|
||||
- `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`.
|
||||
- `zod` (`catalog:forms`) — boundary validation.
|
||||
- `tslib` (`catalog:build`).
|
||||
|
||||
## Test plan
|
||||
|
||||
- Every requirement in the spec delta has a `**Verified by:**` line naming
|
||||
concrete `it(...)` titles in the test files above.
|
||||
- Real implementations only — no mocks. AbortSignal is exercised via
|
||||
`AbortController` + `queueMicrotask` to land a real abort between fallback
|
||||
attempts.
|
||||
- Coverage gate: `provider: "istanbul"`, all four axes at 100%.
|
||||
Reference in New Issue
Block a user