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

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 in openspec/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 in src/.
  • No fetch, no network, no FetchLike consumption — 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/money registerCurrency from the constructor, a hidden side effect that broke tree-shaking.)