Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-ecb (add-currency-provider-ecb)
**Package**: `@suaveplan/currency-provider-ecb`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: EcbProvider implements CurrencyProvider with stable identity
The package SHALL export `EcbProvider`, a class implementing `CurrencyProvider`
from `@suaveplan/currency` with `name = "ecb"`, `supportsFiat = true`, and
`supportsCrypto = false`. The constructor SHALL accept an optional
`NoKeyConfig` (from `@suaveplan/currency-providers`) carrying `baseUrl` and
`fetch` overrides; no API key is required.
#### Scenario: identity flags
- **WHEN** a caller constructs `new EcbProvider()` with no config
- **THEN** `provider.name === "ecb"` AND `provider.supportsFiat === true` AND `provider.supportsCrypto === false`
#### Scenario: custom baseUrl override
- **WHEN** a caller constructs `new EcbProvider({ baseUrl: "https://proxy/ecb" })` and calls `fetchRates()` with an injected `fetch`
- **THEN** the injected fetch is invoked with `"https://proxy/ecb"`
**Verified by:** `src/ecb/ecb.test.ts::"exposes the documented identity flags"` AND `src/ecb/ecb.test.ts::"calls the custom baseUrl when provided"`
---
### Requirement: fetchRates returns EUR-based rates by default
The package SHALL fetch the ECB daily XML feed through `httpRequest()` from
`@suaveplan/currency-providers`, parse the EUR-quoted currency table via the
regex `/currency='([A-Z]+)'\s+rate='([\d.]+)'/g`, and return a
`RateFetchResult` whose `base` is `"EUR"` when the caller passes no `base`
option or explicitly passes `"EUR"`. The returned `rates` map SHALL contain
every currency present in the feed, keyed by ISO-4217 code with numeric
exchange rates parsed via `Number.parseFloat`. The `timestamp` SHALL be the
wall-clock millisecond value at fetch completion.
#### Scenario: default base resolves to EUR
- **WHEN** `fetchRates()` is called with no options against a feed containing USD=1.0870, GBP=0.8358, JPY=162.48, CHF=0.9443
- **THEN** the result has `base === "EUR"` AND `rates.USD === 1.0870` AND `rates.GBP === 0.8358` AND `rates.JPY === 162.48` AND `rates.CHF === 0.9443` AND `timestamp` is a finite positive number
#### Scenario: explicit EUR base
- **WHEN** `fetchRates({ base: "EUR" })` is called against the same feed
- **THEN** the result equals the default-base result
**Verified by:** `src/ecb/ecb.test.ts::"returns EUR-based rates when no base is specified"` AND `src/ecb/ecb.test.ts::"returns EUR-based rates when EUR is explicitly requested"`
---
### Requirement: fetchRates converts to a non-EUR base mathematically
When the caller requests a non-EUR `base`, the package SHALL look up that
base in the EUR-quoted rate map and synthesise the target view by computing
`result.EUR = 1 / baseRate` and `result.<other> = <eurRate> / baseRate` for
every other currency. The requested base SHALL be absent from the returned
`rates` map (a currency does not quote itself).
#### Scenario: USD base derived from EUR feed
- **WHEN** `fetchRates({ base: "USD" })` is called against a feed where USD=1.0870 and GBP=0.8358 (EUR-quoted)
- **THEN** the result has `base === "USD"` AND `rates.EUR` is within 1e-4 of `1 / 1.0870` AND `rates.GBP` is within 1e-4 of `0.8358 / 1.0870` AND `rates.USD === undefined`
**Verified by:** `src/ecb/ecb.test.ts::"converts to a non-EUR base by inverting through the EUR feed"`
---
### Requirement: targets filter restricts the returned rate map
When `options.targets` is provided, the package SHALL return only the
currencies present in `targets` AND in the resolved rate map. Currencies
listed in `targets` that are absent from the feed SHALL be silently dropped
UNLESS every requested target is absent, in which case the package SHALL
raise `CurrencyProviderError(UNKNOWN_CURRENCY)`.
#### Scenario: targets intersect the feed
- **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called against a feed containing USD, GBP, JPY, CHF
- **THEN** the result `rates` contains exactly two keys, `USD` and `GBP`, AND `rates.JPY === undefined`
#### Scenario: all requested targets absent
- **WHEN** `fetchRates({ base: "EUR", targets: ["ZZZ", "QQQ"] })` is called against a feed containing only USD, GBP, JPY, CHF
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` AND whose `providerName === "ecb"`
**Verified by:** `src/ecb/ecb.test.ts::"restricts the returned rates to the requested targets"` AND `src/ecb/ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"`
---
### Requirement: HTTP failures surface as typed CurrencyProviderError(HTTP_ERROR)
The package SHALL route every HTTP call through `httpRequest()` from
`@suaveplan/currency-providers`. When the upstream returns a non-2xx
response, the call SHALL reject with `CurrencyProviderError` whose `code`
is `CURRENCY_PROVIDER.HTTP_ERROR`, whose `providerName === "ecb"`, and whose
`status` matches the HTTP status code. The package SHALL NOT hand-roll a
`fetch()` call site or a `!response.ok` block.
#### Scenario: 5xx response
- **WHEN** the injected `fetch` resolves with `new Response("", { status: 503, statusText: "Down" })`
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.HTTP_ERROR` AND `status === 503` AND `providerName === "ecb"`
**Verified by:** `src/ecb/ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"`
---
### Requirement: PARSE_ERROR raised for an unparseable feed body
When the upstream returns 2xx but the body contains zero matches for the
currency-rate regex, the package SHALL raise
`CurrencyProviderError(PARSE_ERROR)`. The package SHALL NOT cache or return
an empty rate map silently.
#### Scenario: empty body
- **WHEN** the injected `fetch` resolves with `new Response("<garbage/>", { status: 200 })`
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.PARSE_ERROR` AND `providerName === "ecb"`
**Verified by:** `src/ecb/ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"`
---
### Requirement: UNSUPPORTED_BASE raised when the requested base is missing from the feed
When the caller requests a non-EUR `base` that is not present in the
EUR-quoted feed (e.g. `"XYZ"`), the package SHALL raise
`CurrencyProviderError(UNSUPPORTED_BASE)`.
#### Scenario: unknown non-EUR base
- **WHEN** `fetchRates({ base: "XYZ" })` is called against a feed that does not contain `XYZ`
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` AND `providerName === "ecb"`
**Verified by:** `src/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"`
---
### Requirement: AbortSignal from RateFetchOptions is forwarded to httpRequest
When `options.signal` is set, the package SHALL forward that signal to
`httpRequest({ signal })`. The slim-core helper composes it with any timeout
signal via `AbortSignal.any`; aborting the caller signal SHALL abort the
in-flight upstream fetch.
#### Scenario: caller aborts the call
- **WHEN** the injected `fetch` returns a Promise that rejects on `signal.abort()` and the caller calls `controller.abort(new Error("cancelled"))` after issuing `fetchRates({ signal: controller.signal })`
- **THEN** the call rejects with an Error whose message contains `"cancelled"`
**Verified by:** `src/ecb/ecb.test.ts::"forwards the caller AbortSignal to the upstream fetch"`
---
### Requirement: Injectable FetchLike for deterministic tests and proxying
The constructor SHALL accept `NoKeyConfig.fetch: FetchLike` (imported from
`@suaveplan/types`) and use it for every HTTP call. When `fetch` is not
provided, the package SHALL fall back to `globalThis.fetch`. The package
SHALL NOT capture or close over `globalThis.fetch` at module-load time —
the fallback resolves at call time so test setups that replace
`globalThis.fetch` after construction still work.
#### Scenario: explicit fetch wins over globalThis.fetch
- **WHEN** `new EcbProvider({ fetch: recordedFetch })` is instantiated and `fetchRates()` is called
- **THEN** `recordedFetch` is invoked exactly once with the resolved URL AND `globalThis.fetch` is never invoked
**Verified by:** `src/ecb/ecb.test.ts::"uses the injected fetch when provided"`
---
### Requirement: Universal-tier purity and zero side effects
The package SHALL be importable in browsers, Web Workers, Node.js, and Bun
without any environment-specific shim. The package SHALL declare
`"sideEffects": false` in `package.json` and SHALL NOT register itself with
any registry at module-load time. The package SHALL ship explicit named
re-exports in every barrel (no `export *`).
#### Scenario: package.json declares sideEffects false
- **WHEN** the package.json is read at build time
- **THEN** `pkg.sideEffects === false`
#### Scenario: barrel uses explicit named exports
- **WHEN** the contents of `src/ecb/index.ts` and `src/index.ts` are read
- **THEN** neither file contains the substring `export *` (other than `export * as` namespace forms, of which there are none in this package)
**Verified by:** `src/ecb/ecb.test.ts::"exposes the documented identity flags"` AND `tests/package-shape.test.ts::"declares sideEffects false and explicit-named barrels"`
<!-- markdownlint-enable MD024 -->