Files
2026-05-27 23:29:53 +01:00

103 lines
5.9 KiB
Markdown

# Add @suaveplan/currency-provider-virtual
**Package**: `@suaveplan/currency-provider-virtual`
**Status**: PROPOSED
**Type**: new package (sibling of `@suaveplan/currency-providers` slim core)
- **Design**: ./design.md
- **Spec delta**: ./specs/currency-provider-virtual/spec.md
## Why
The 2026-05-17 `slim-currency-providers` refactor split the original 9-provider
bundle into a slim core (`@suaveplan/currency-providers`) plus nine sibling
`@suaveplan/currency-provider-<name>` packages. Of the nine, eight wrap remote
HTTP APIs (ECB, Fixer, OXR, Binance, CoinGecko, CoinMarketCap, CryptoCompare,
Kraken); the ninth — **virtual** — serves operator-defined virtual / game
currencies whose rates are programmable in memory and never round-trip a
network.
This change lands the virtual sibling. It is the only sibling that does NOT
consume `httpRequest` / `FetchLike` from the slim core (it has no upstream to
fetch) and that DOES set `supportsVirtual: true` on the
`CurrencyProvider` contract.
## What changes
### ADDED
- New package `@suaveplan/currency-provider-virtual` at
`packages/finance/currency-provider-virtual/` (universal-tier, `0.1.0`).
- `VirtualCurrencyProvider` — implements `CurrencyProvider` from
`@suaveplan/currency`. Constructor takes an initial rate table and an
optional `Clock` (default `Date.now`) used for `fetchRates` timestamps. No
HTTP, no API key, no `FetchLike`.
- Programmable rate-table API: `setRate(from, to, rate)`,
`removeRate(from, to): boolean`, `clear()`, plus the read helpers
`hasRate(from, to)`, `getRate(from, to)`, `listCurrencies(): readonly string[]`.
- `fetchRates(options?)` — returns every rate denominated against the
requested `base` (default the provider's `defaultBase`), filtered by the
optional `targets` set, with `timestamp` derived from the injected `Clock`.
- Raises `CurrencyProviderError(UNKNOWN_CURRENCY)` (registered in
`@suaveplan/currency-providers`) when `fetchRates` is asked for a base that
is not present in the rate table — there is no triangulation, the registry
can be empty.
### KEPT (inherited)
- `CurrencyProvider` contract from `@suaveplan/currency` (unchanged).
- `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` from
`@suaveplan/currency-providers` (already present in the 8-code slim-core
registry).
- Universal-tier purity (zero browser / Node APIs in `src/`), explicit named
barrels, `@suaveplan/testing/runner` imports, 100/100/100/100 istanbul
coverage.
## Source mapping
The upstream reference shape lived in
`../suaveplan/packages/finance/currency-providers/src/virtual/virtual.ts`
(pre-slim, now removed). That upstream shape coupled the provider to
`@suaveplan/money` (`registerCurrency`) and stored rates as a record of
`VirtualCurrencyConfig { rate, name, symbol, exponent?, issuerId? }` keyed by
currency code, assuming a single shared base.
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Programmable per-currency rate table | ✅ | ✅ | Surface generalised — `setRate(from, to, rate)` lets callers store arbitrary directed pairs (game internal rates, partial graphs) rather than only "one base, many targets". |
| `fetchRates({ base, targets })` | ✅ | ✅ | |
| `supportsVirtual: true` flag | ✅ | ✅ | |
| Triangulation from any virtual base | ✅ | 🚫 | Scoped out: the slim-core / `CurrencyConverter` already handles triangulation via the rate cache; duplicating it here would split the algorithm across two packages. Callers that need triangulated rates either pre-populate both directions with `setRate` or wire the provider through `CurrencyConverter` which triangulates from the cached `RateFetchResult`. |
| Auto-registering currencies via `@suaveplan/money` `registerCurrency` | ✅ | 🚫 | Scoped out: the slim refactor explicitly dropped the `@suaveplan/money` dep so the slim core stays universal and zero-side-effect. Callers that want money-registry integration call `registerCurrency` themselves before constructing the provider — keeps this package side-effect-free per `sideEffects: false`. |
| `Clock`-injected `timestamp` for deterministic tests | ⬆️ | ⬆️ | Enhanced: upstream hard-coded `Date.now()`. We inject a `Clock` so `FakeClock`-driven tests get deterministic timestamps without monkey-patching `Date`. |
| `UNKNOWN_CURRENCY` typed error | ⬆️ | ⬆️ | Enhanced: upstream threw plain `Error("VirtualCurrencyProvider: no rates for base UNKNOWN")`. We throw `CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` with `providerName` populated. |
| `defineCurrencyProvider`-friendly | ⬆️ | ⬆️ | Enhanced: the provider exposes a stable class with the programmable methods AND can be wrapped via `defineCurrencyProvider` if a consumer prefers the object-literal idiom. |
The enterprise audit reference is
`../suaveplan/enterprise/currency-providers.md`; the virtual provider is one
row in that audit and carried no specific gaps beyond the registry-typed-error
gap, which is closed here.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual` to enumerate.
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual`).
The package is a leaf sibling — the future commerce / gaming stack downstream
will consume it directly, but those packages are still pending migration.
## Out of scope
- Triangulation across the rate table (see Parity Checklist row).
- `@suaveplan/money` `registerCurrency` integration (see Parity Checklist row).
- Persistence — the rate table is purely in-memory by design (this provider's
whole purpose). Callers that need persistence layer a storage adapter on top
and reload the table at construction.
- `AbortSignal` plumbing on `fetchRates` — the operation is synchronous over
an in-memory `Map`; an injected `signal` would have nothing to abort. The
optional `signal` from `RateFetchOptions` is accepted by the type but
unused.