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.
|
||||
Reference in New Issue
Block a user