78 lines
3.5 KiB
Markdown
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.)
|