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

5.3 KiB

Design — @suaveplan/currency-provider-ecb

File layout

packages/finance/currency-provider-ecb/
  package.json
  README.md
  tsconfig.json
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts
  tests/setup/polyfills.ts
  src/
    index.ts                      # explicit named barrel — exports EcbProvider
    ecb/
      index.ts                    # explicit named barrel
      ecb.ts                      # EcbProvider class — ~200 lines
      ecb.test.ts                 # vitest tests, runner-neutral imports
      ecb.md                      # sibling docs

Module contract

EcbProvider consumes the slim core directly:

import { httpRequest, CurrencyProviderError, CURRENCY_PROVIDER_CODES }
    from "@suaveplan/currency-providers";
import type { NoKeyConfig } from "@suaveplan/currency-providers";
import type {
    CurrencyProvider, RateFetchOptions, RateFetchResult,
} from "@suaveplan/currency";
import type { FetchLike } from "@suaveplan/types";

FetchLike is imported from @suaveplan/types (not re-exported from the slim core) per the user instruction and slim-core convention — the slim core itself imports FetchLike from @suaveplan/types, so doing the same here keeps the dep graph shallow and avoids a re-export hop.

Algorithm

  1. Resolve baseUrl → default "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml".
  2. Resolve fetch → default globalThis.fetch.
  3. Call httpRequest({ url, fetch, providerName: "ecb", signal }) — non-2xx raises CurrencyProviderError(HTTP_ERROR) automatically.
  4. Read response body via response.text().
  5. Parse via the same regex upstream uses: /currency='([A-Z]+)'\s+rate='([\d.]+)'/g.
    • If the parsed map is empty, raise PARSE_ERROR (upstream silently returns an empty map; this sibling treats that as a parse failure to protect downstream caches).
  6. Determine requested base (options?.base ?? "EUR").
  7. If requested base is "EUR", run filterTargets() and return.
  8. Otherwise, look up the requested base in the EUR-quoted map. If absent, raise UNSUPPORTED_BASE (replaces upstream's plain Error("ECB does not provide rate for X")).
  9. Convert: converted.EUR = 1 / baseRate, and for every other code code !== requestedBase, converted[code] = rate / baseRate. Run filterTargets() and return.
  10. filterTargets(result, targets) — if targets is provided, intersect. When the resulting rates map is empty AND targets was non-empty AND at least one target was requested, raise UNKNOWN_CURRENCY so callers discover the misconfiguration instead of getting an empty success.

Error matrix

Code When raised Test
HTTP_ERROR non-2xx upstream response ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"
PARSE_ERROR response body contains zero matching currency tags ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"
UNSUPPORTED_BASE requested non-EUR base is absent from the EUR-quoted feed ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"
UNKNOWN_CURRENCY every entry in options.targets is absent from the resolved rate map ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"
UPSTREAM_ERROR reserved — raised when the upstream is reachable, returns 2xx, but the body indicates a logical error (kept in the registry for future use; not exercised by the ECB feed which has no such error envelope). Code stays declared in the slim core as committed there; this package does not delete it. —

Rule §2c (never delete spec elements to satisfy a gate) applies — the ECB feed format is stable and does not surface logical errors inside a 2xx, so UPSTREAM_ERROR is not raised by this sibling. It remains in the slim core's registry for other siblings.

Test strategy

  • No vi.mock / vi.stubGlobal. Tests construct a FetchLike closure via a recordingFetch(handler) helper, then inject it through new EcbProvider({ fetch }). This matches the slim core's own http.test.ts and obeys CLAUDE rule 2 ("no mocks — real services only").
  • Fixture XML mirrors the upstream test fixture verbatim (USD, GBP, JPY, CHF rates from 2026-02-27) so the parse + conversion paths are pinned to known-good numerical outputs.
  • AbortSignal test uses a real AbortController and a fetch closure that resolves on signal-abort, mirroring the slim core's http.test.ts pattern.
  • Coverage target: 100/100/100/100 Istanbul (every branch — EUR base / non-EUR base / target filter / each error path / custom baseUrl / injected fetch / AbortSignal forward).

Subpath exports

Export Purpose
. barrel — re-exports EcbProvider
./ecb/ecb direct deep import for tree-shaking-strict consumers
./package.json tooling

Subpath count matches the single feature module (ecb) — no inflation.

Universal-tier purity

  • No node: imports.
  • No browser-only globals (window, document).
  • globalThis.fetch, Response, URL are universally available (Node 18+, Bun, browsers, Web Workers).
  • Number.parseFloat, Object.entries, RegExp are ECMAScript intrinsics.

No polyfills required; tests/setup/polyfills.ts is an empty marker matching the slim-core pattern.