Initial Commit
This commit is contained in:
116
openspec/archive/2026-05-17-add-currency-provider-ecb/design.md
Normal file
116
openspec/archive/2026-05-17-add-currency-provider-ecb/design.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Design — @suaveplan/currency-provider-ecb
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```ts
|
||||
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.
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
|
||||
|
||||
const converter = new CurrencyConverter({ provider: new EcbProvider() });
|
||||
```
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1,53 @@
|
||||
# Tasks — add @suaveplan/currency-provider-ecb
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/currency-providers.md` end-to-end (ECB row)
|
||||
- [x] 1.2 Read slim-core archive `openspec/archive/2026-05-17-slim-currency-providers/`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` §0c, §13, §14
|
||||
- [x] 1.4 Draft `proposal.md` with `## Source mapping` and `## Parity Checklist`
|
||||
- [x] 1.5 Draft `design.md` with file layout, algorithm, error matrix
|
||||
- [x] 1.6 Draft `specs/currency-provider-ecb/spec.md` — every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Create `packages/finance/currency-provider-ecb/` skeleton (package.json, tsconfig, vite.config, vitest.config, tests/setup/polyfills.ts)
|
||||
- [x] 2.2 Implement `src/ecb/ecb.ts` — `EcbProvider` class consuming `httpRequest` from slim core
|
||||
- [x] 2.3 Wire `FetchLike` from `@suaveplan/types` (NOT re-imported from slim core)
|
||||
- [x] 2.4 Raise typed `CurrencyProviderError` with `HTTP_ERROR` / `PARSE_ERROR` / `UNSUPPORTED_BASE` / `UNKNOWN_CURRENCY`
|
||||
- [x] 2.5 Forward `RateFetchOptions.signal` through `httpRequest`
|
||||
- [x] 2.6 Author `src/ecb/index.ts` + `src/index.ts` explicit named barrels
|
||||
- [x] 2.7 Add subpath exports to `package.json` (`.`, `./ecb/ecb`, `./package.json`)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/ecb/ecb.test.ts` — every scenario in the spec delta
|
||||
- [x] 3.2 Tests import runner primitives from `@suaveplan/testing/runner`
|
||||
- [x] 3.3 No `vi.mock` / `vi.stubGlobal` — use injected `FetchLike` closure
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `src/ecb/ecb.md` (≥200 words, basic + advanced examples, full API)
|
||||
- [x] 4.2 `README.md` with Install + Quick start + Modules section linking `src/ecb/ecb.md`
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-ecb` — green
|
||||
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/currency-provider-ecb` — 100/100/100/100
|
||||
- [x] 5.3 `bunx turbo build --filter=@suaveplan/currency-provider-ecb` — green
|
||||
- [x] 5.4 Banned-pattern scan zero output
|
||||
- [x] 5.5 Barrel-shape scan zero output (no `export *` in feature barrels)
|
||||
- [x] 5.6 Test-runner import scan zero output
|
||||
|
||||
## 6. Root-doc refresh (§13b)
|
||||
|
||||
- [x] 6.1 Add row to `STATUS.md` under Completed
|
||||
- [x] 6.2 Add paragraph to `SUMMARY.md`
|
||||
- [x] 6.3 Regenerate `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
|
||||
- [x] 6.4 Regenerate `MISSING.yml` (`bun scripts/generate-missing.ts`) — skip if generator fails
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold `specs/currency-provider-ecb/spec.md` into `openspec/specs/currency-provider-ecb/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/add-currency-provider-ecb/` → `openspec/archive/2026-05-17-add-currency-provider-ecb/`
|
||||
Reference in New Issue
Block a user