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

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-ecb at packages/finance/currency-provider-ecb/.
  • EcbProvider class implementing CurrencyProvider from @suaveplan/currency.
    • Constructor: new EcbProvider(config?: NoKeyConfig) — optional baseUrl override and optional injectable fetch.
    • Static identity: name = "ecb", supportsFiat = true, supportsCrypto = false.
    • fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> — fetches the ECB daily XML feed through httpRequest() 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. Honours options.targets filtering and options.signal cancellation.
  • Single subpath export ./ecb/ecb (plus the root . barrel) exposing the EcbProvider class.
  • 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_CODES registry for every error path (HTTP_ERROR via httpRequest, plus PARSE_ERROR, UPSTREAM_ERROR, UNSUPPORTED_BASE, UNKNOWN_CURRENCY raised directly).
  • NoKeyConfig shared config type (ECB takes no API key).
  • FetchLike injection 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 wire registry.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 CurrencyProvider contract 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: ...") → CurrencyProviderError with stable code (HTTP_ERROR from httpRequest, UNSUPPORTED_BASE / UNKNOWN_CURRENCY / PARSE_ERROR raised directly).
  • Shared httpRequest helper. Upstream calls globalThis.fetch directly with a hand-rolled !response.ok block; this sibling uses the slim core's httpRequest() so abort-signal composition, timeout derivation, and the typed HTTP error path are uniform across every sibling.
  • FetchLike injection. Upstream is hard-bound to globalThis.fetch; this sibling accepts NoKeyConfig.fetch so tests use a recording closure (no vi.mock / vi.stubGlobal) and production passes nothing.
  • AbortSignal propagation. RateFetchOptions.signal is forwarded through httpRequest; upstream ignores it.
  • Empty XML protection. Upstream silently returns an empty rate map if the XML has no matches; this sibling raises PARSE_ERROR so consumers do not cache an empty result.
  • Runner-agnostic tests. Tests import from @suaveplan/testing/runner per AGENTS.md §7; upstream imports directly from vitest.

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 a workspace:^ 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() });