8.9 KiB
Add @suaveplan/currency-provider-ecb sibling provider package
Package: @suaveplan/currency-provider-ecb
Status: PROPOSED
Type: new-package
- Design: ./design.md
- Spec delta: ./specs/currency-provider-ecb/spec.md
Why
The 2026-05-17 slim-currency-providers change extracted the nine concrete
provider classes from @suaveplan/currency-providers and committed to landing
each as its own @suaveplan/currency-provider-<name> sibling package. This
change ships the first such sibling: the European Central Bank (ECB) provider.
ECB is the lowest-friction fiat provider in the catalogue — it is free, requires
no API key, publishes a stable daily XML feed, and covers ~30 major fiat
currencies. Shipping it first lets downstream consumers (commerce stack, price
display, conversion helpers) wire a working CurrencyConverter without
provisioning credentials, and proves the sibling shape end-to-end against the
slimmed core.
What changes
ADDED
- New package
@suaveplan/currency-provider-ecbatpackages/finance/currency-provider-ecb/. EcbProviderclass implementingCurrencyProviderfrom@suaveplan/currency.- Constructor:
new EcbProvider(config?: NoKeyConfig)— optionalbaseUrloverride and optional injectablefetch. - Static identity:
name = "ecb",supportsFiat = true,supportsCrypto = false. fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>— fetches the ECB daily XML feed throughhttpRequest()from the slim core, parses the EUR-based rate table, and either returns the raw EUR view or mathematically converts to a caller-requested non-EUR base. Honoursoptions.targetsfiltering andoptions.signalcancellation.
- Constructor:
- Single subpath export
./ecb/ecb(plus the root.barrel) exposing theEcbProviderclass. - 100/100/100/100 Istanbul coverage gate.
KEPT (consumed unchanged from @suaveplan/currency-providers)
httpRequest()for every HTTP call (no hand-rolled fetch+error block).CurrencyProviderError+CURRENCY_PROVIDER_CODESregistry for every error path (HTTP_ERRORviahttpRequest, plusPARSE_ERROR,UPSTREAM_ERROR,UNSUPPORTED_BASE,UNKNOWN_CURRENCYraised directly).NoKeyConfigshared config type (ECB takes no API key).FetchLikeinjection seam, imported from@suaveplan/types.
NOT INCLUDED (out of scope, deferred to follow-up changes)
- Auto-registration with any
CurrencyProviderRegistry. The package exposes the class only; consumers wireregistry.register(new EcbProvider()). - Rate-limit / retry / OTel / historical-rate / WebSocket / aggregator
surfaces — same deferral as the slim-core archive's
## Out of scope.
Out of scope
- The eight other sibling provider packages (
fixer,open-exchange-rates,binance,coingecko,coinmarketcap,cryptocompare,kraken,virtual) — each lands under its own OpenSpec change per AGENTS.md §0c "one change per package". - Modifying the
CurrencyProvidercontract in@suaveplan/currency— the contract is consumed as-is. - Caching, request coalescing, observability — same wave as the slim-core deferrals; will land as composable wrappers consuming any sibling.
Source mapping
This package is derived from the upstream @suaveplan/currency-providers
bundle (../suaveplan/packages/finance/currency-providers/). The slim-core
2026-05-17 archive committed the ECB module to ship as its own sibling.
Enterprise audit doc:
../suaveplan/enterprise/currency-providers.md L8-L37 (current-state) /
L38-L80 (gaps to close) — the audit's EcbProvider row is the source of
truth for the contract.
| Migrated module | Upstream source |
|---|---|
EcbProvider class |
../suaveplan/packages/finance/currency-providers/src/fiat/ecb.ts L1-L92 |
EcbProvider tests (pattern, not 1:1) |
../suaveplan/packages/finance/currency-providers/src/fiat/ecb.test.ts L1-L123 |
EcbProvider docs (pattern) |
../suaveplan/packages/finance/currency-providers/src/fiat/ecb.md L1-L55 |
Slim-core archive precedent:
openspec/archive/2026-05-17-slim-currency-providers/proposal.md L84-L99
(source-mapping table committing each provider to a sibling package);
L128 ("@suaveplan/currency-provider-ecb — [x] consumes registry / factory /
httpRequest / configs / error").
Differences from upstream
Every difference is a hard upgrade required by the slim-core contract:
- Typed errors only. Upstream
throw new Error("ECB API error: ...")→CurrencyProviderErrorwith stable code (HTTP_ERRORfromhttpRequest,UNSUPPORTED_BASE/UNKNOWN_CURRENCY/PARSE_ERRORraised directly). - Shared
httpRequesthelper. Upstream callsglobalThis.fetchdirectly with a hand-rolled!response.okblock; this sibling uses the slim core'shttpRequest()so abort-signal composition, timeout derivation, and the typed HTTP error path are uniform across every sibling. FetchLikeinjection. Upstream is hard-bound toglobalThis.fetch; this sibling acceptsNoKeyConfig.fetchso tests use a recording closure (novi.mock/vi.stubGlobal) and production passes nothing.- AbortSignal propagation.
RateFetchOptions.signalis forwarded throughhttpRequest; upstream ignores it. - Empty XML protection. Upstream silently returns an empty rate map if the
XML has no matches; this sibling raises
PARSE_ERRORso consumers do not cache an empty result. - Runner-agnostic tests. Tests import from
@suaveplan/testing/runnerper AGENTS.md §7; upstream imports directly fromvitest.
Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
EcbProvider class with name / supportsFiat / supportsCrypto |
✅ | ✅ | identity preserved verbatim |
fetchRates({ base, targets }) returning RateFetchResult |
✅ | ✅ | options forwarded unchanged |
| EUR base default | ✅ | ✅ | matches options?.base ?? "EUR" upstream |
| Non-EUR base via mathematical conversion | ✅ | ✅ | identical algorithm, behaviour bit-for-bit |
targets filtering |
✅ | ✅ | identical helper |
Custom baseUrl override |
✅ | ✅ | via NoKeyConfig.baseUrl |
| HTTP error → throw | ✅ | ⬆️ | now typed CurrencyProviderError(HTTP_ERROR) instead of plain Error |
| Missing base in feed → throw | ✅ | ⬆️ | now typed CurrencyProviderError(UNSUPPORTED_BASE) |
| XML parse via regex | ✅ | ✅ | identical regex; same zero-dep approach |
AbortSignal propagation |
🚫 | ⬆️ | added — forwarded through httpRequest |
Injectable fetch for tests |
🚫 | ⬆️ | added — NoKeyConfig.fetch, no vi.mock |
| Empty/unparseable XML guard | 🚫 | ⬆️ | added — raises PARSE_ERROR |
UNKNOWN_CURRENCY for empty target intersection |
🚫 | ⬆️ | added — raised when every requested target is absent |
| Auto-registry side effect on import | 🚫 | 🚫 | Scoped out: package exposes class only; consumer calls registry.register(new EcbProvider()). Same shape committed by slim-core archive (design.md "no module-level side effects"). |
| Retry / rate-limit / OTel | 🚫 | 🚫 | Scoped out: same deferral as slim-core archive; will land as composable wrappers consuming any sibling. |
| Historical rate endpoint | 🚫 | 🚫 | Scoped out: ECB feed is daily-latest only; historical archive lives at a different URL and is a separate feature (fetchHistoricalRates) tracked against enterprise/currency-providers.md §4. |
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/currency-provider-ecb to enumerate.
- No current dependents (verified by
bunx turbo ls --filter=...@suaveplan/currency-provider-ecb). The package is brand new; the commerce stack downstream is still pending migration and will pick this up as aworkspace:^dependency in its own change.
Foundation-package check
This is not a foundation package. The slim core @suaveplan/currency-providers
is consumed unchanged; @suaveplan/currency is consumed unchanged. No
behaviour-change propagation per §13a is required.
Migration
N/A — this is a new package. The "before" path (importing EcbProvider from
@suaveplan/currency-providers) was removed by the 2026-05-17 slim-core
change; this package restores the import surface at the sibling location
committed by that change.
After
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
});
Direct instantiation without the registry works identically:
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({ provider: new EcbProvider() });