184 lines
8.4 KiB
Markdown
184 lines
8.4 KiB
Markdown
# 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%.
|