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

78 lines
3.5 KiB
Markdown

# 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
```text
@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 `Map`s 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.)