103 lines
5.9 KiB
Markdown
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.
|