8.4 KiB
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.
┌──────────────────────────┐
│ 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.
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 === toshort-circuit.- otherwise — the
nameof the provider whosefetchRatesproduced 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 concreteit(...)titles in the test files above. - Real implementations only — no mocks. AbortSignal is exercised via
AbortController+queueMicrotaskto land a real abort between fallback attempts. - Coverage gate:
provider: "istanbul", all four axes at 100%.