3.5 KiB
Design: @suaveplan/currency-provider-virtual
Non-normative. The normative requirements live in
./specs/currency-provider-virtual/spec.md. The cross-cutting foundation-with-sibling-plugins architecture is documented once inopenspec/archive/2026-05-17-slim-currency-providers/design.md— this document covers only what is specific to the virtual sibling.
Shape
@suaveplan/currency-provider-virtual
└── src/
├── index.ts # explicit named barrel
├── error-codes/
│ ├── error-codes.ts # re-exports UNKNOWN_CURRENCY helper
│ └── error-codes.md
└── provider/
├── provider.ts # VirtualCurrencyProvider class
├── provider.types.ts # rate-table & options types
├── provider.test.ts # all behaviour incl. UNKNOWN_CURRENCY
└── provider.md
Rate-table storage
Rates are stored as a nested Map<from, Map<to, number>>. Pair lookup is
O(1); enumeration for fetchRates is O(n) over the inner map of the
requested base. Storing the table as nested Maps rather than a flat
Map<"${from}->${to}", number> keeps listCurrencies cheap (just the outer
key set) and avoids string-concatenation collisions when currency codes
contain hyphens (USDC-GOLD, GAME-1-COIN).
Insertion replaces an existing rate silently — setRate is upsert, mirroring
the upstream behaviour. This is intentional: virtual currencies are
operator-controlled and "the operator changed the rate" is the normal flow,
not an error.
Triangulation is deliberately absent
The upstream provider derived rates[base] for non-default bases by inverting
the stored rate (1 / baseVc.rate) and computing cross-rates
(otherRate / baseVc.rate). Genesis pushes that into CurrencyConverter:
the provider returns the rates it has, the converter triangulates via the
cache. This keeps two responsibilities separate and avoids the upstream's
implicit assumption that every virtual currency converts through a single
shared EUR base. Callers who want bidirectional rates pre-populate them
explicitly — usually trivial in a game-economy seeder.
Clock injection
Clock = () => number from @suaveplan/types (ms since epoch). The
constructor accepts an optional clock (default () => Date.now()); every
fetchRates result stamps timestamp = clock(). Tests pass a FakeClock
adapter (() => fakeClock.now()) for deterministic timestamps without
monkey-patching Date.
Error handling
fetchRates({ base }) consults the rate table for base. If base is not
present, the provider throws
CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY) with
providerName: "virtual-currency". This is the only error path: setRate
upserts, removeRate is idempotent (returns false for missing pairs),
clear always succeeds. UNKNOWN_CURRENCY was added to the slim-core
registry on the original 2026-05-17 land and is the canonical typed error
for "asked about a currency the provider doesn't know."
Universal-tier purity
- No
node:/bun:/ DOM imports anywhere insrc/. - No
fetch, no network, noFetchLikeconsumption — this is the entire point of the package. sideEffects: false— constructing the provider never mutates global state. (Contrast with the upstream form which called@suaveplan/moneyregisterCurrencyfrom the constructor, a hidden side effect that broke tree-shaking.)