Files
suaveplan/openspec/archive/2026-05-17-add-currency/design.md
2026-05-27 23:29:53 +01:00

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 === 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%.