Files
suaveplan/openspec/archive/2026-05-17-slim-currency-providers/design.md
2026-05-27 23:29:53 +01:00

308 lines
12 KiB
Markdown

# Design: Slim @suaveplan/currency-providers — foundation-with-sibling-plugins
> Non-normative. The normative requirements live in
> `./specs/currency-providers/spec.md`. This document explains the
> architecture the slim refactor establishes, and is the canonical
> cross-cutting reference for the nine sibling `@suaveplan/currency-provider-<name>`
> packages landing in the follow-up wave. Each sibling's own `design.md`
> SHALL link back to this file rather than duplicating its contents
> (per AGENTS.md §0c).
## Shape
```text
@suaveplan/currency-providers (this package, slim core)
├── CurrencyProvider contract (re-exported from @suaveplan/currency)
├── CurrencyProviderRegistry — name → instance lookup
├── defineCurrencyProvider — factory helper for object-literal providers
├── httpRequest / composeAbortSignal — shared HTTP utilities
├── ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase — config types
└── CURRENCY_PROVIDER_CODES (8) + CurrencyProviderError
@suaveplan/currency-provider-ecb ──┐
@suaveplan/currency-provider-fixer ──┤
@suaveplan/currency-provider-oxr ──┤
@suaveplan/currency-provider-binance ──┤ each depends on the slim
@suaveplan/currency-provider-coingecko ──┤ core for the registry +
@suaveplan/currency-provider-coinmarketcap─┤ factory + httpRequest +
@suaveplan/currency-provider-cryptocompare ┤ error + configs
@suaveplan/currency-provider-kraken ──┤
@suaveplan/currency-provider-virtual ──┘
```
The slim core has **zero knowledge** of any concrete provider. Adding a tenth
provider (say Yahoo Finance) requires zero change to the core — the new
sibling package implements the `CurrencyProvider` contract using
`defineCurrencyProvider` + `httpRequest`, declares the core as a peer dep,
and ships independently.
This shape mirrors:
- `@suaveplan/network` (core) + `@suaveplan/network-plugin-*` (transports)
- `@suaveplan/drive` (core) + `@suaveplan/drive-plugin-*` (chunks, etc.)
- `@suaveplan/i18n` (core) + `@suaveplan/i18n-backend-*` (backends)
## Why a registry at all
The `CurrencyConverter` from `@suaveplan/currency` already accepts a
`provider` and a `fallbackProviders` array — callers can wire providers
directly without the registry. The registry exists for callers that:
1. Wire many providers from configuration (e.g. an admin UI that toggles
providers on/off).
2. Need a single point that owns the canonical instance of each provider
(avoiding the "constructed twice with different `fetch` closures" bug).
3. Want a uniform "look up by name" surface that the future
`AggregatorProvider` and `RateLimitedProvider` wrappers can consume.
The registry is therefore **opt-in convenience**, not a required indirection.
`new CurrencyConverter({ provider: new EcbProvider() })` continues to work
once `@suaveplan/currency-provider-ecb` is installed.
## CurrencyProviderRegistry contract
```ts
class CurrencyProviderRegistry {
/**
* Register a provider under its own `provider.name`. Throws
* CurrencyProviderError(PROVIDER_ALREADY_REGISTERED) when the name is
* already in use — explicit replacement requires unregister + register.
*/
register(provider: CurrencyProvider): void;
/**
* Look up a registered provider by name. Throws
* CurrencyProviderError(PROVIDER_NOT_REGISTERED) when no provider has
* been registered under that name. This is the contract every
* caller-facing surface (CurrencyConverter wiring, admin UIs,
* aggregator wrappers) uses to resolve providers.
*/
get(name: string): CurrencyProvider;
/** Non-throwing probe — `true` when `get(name)` would succeed. */
has(name: string): boolean;
/** Sorted snapshot of currently registered provider names. */
list(): readonly string[];
/** Remove a registration; returns `true` when something was removed. */
unregister(name: string): boolean;
}
```
Determinism notes:
- `list()` returns a sorted snapshot (not insertion order) so two registries
with the same set of providers list them identically — useful for
deterministic golden-file tests in downstream packages.
- `register` is idempotent only at the identity level: registering the same
instance under the same name twice throws `PROVIDER_ALREADY_REGISTERED`,
exactly as registering two different instances under the same name throws.
Idempotent "replace if newer" semantics belong in a wrapper, not the core.
- The registry stores `CurrencyProvider` instances by reference — there is
no cloning, no freezing, no defensive copy. Providers are expected to be
immutable shells around their config.
## defineCurrencyProvider factory
```ts
interface DefineCurrencyProviderInput {
readonly name: string;
readonly supportsCrypto: boolean;
readonly supportsFiat: boolean;
readonly supportsVirtual?: boolean;
readonly fetchRates: (
options?: RateFetchOptions
) => Promise<RateFetchResult>;
}
function defineCurrencyProvider(
input: DefineCurrencyProviderInput
): CurrencyProvider;
```
Returns an `Object.freeze`-d implementation of the `CurrencyProvider`
contract. The factory exists so sibling packages can declare a provider in
the "config-driven" idiom:
```ts
// In @suaveplan/currency-provider-ecb
export function createEcbProvider(config?: NoKeyConfig): CurrencyProvider {
const baseUrl = config?.baseUrl ?? DEFAULT_URL;
const fetchImpl = config?.fetch ?? globalThis.fetch;
return defineCurrencyProvider({
name: "ecb",
supportsCrypto: false,
supportsFiat: true,
async fetchRates(options) {
const xml = await httpRequest({
url: baseUrl,
fetch: fetchImpl,
providerName: "ecb",
signal: options?.signal,
}).then((r) => r.text());
// … parse, build result …
},
});
}
```
Class-based providers (`class EcbProvider implements CurrencyProvider`)
remain equally supported — `defineCurrencyProvider` is the lower-ceremony
path, not a mandate.
## httpRequest / composeAbortSignal
The nine deleted provider implementations all repeated this block:
```ts
const response = await this.fetchImpl(url);
if (!response.ok) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
`${PROVIDER_NAME} API error: ${response.status} ${response.statusText}`,
{ providerName: PROVIDER_NAME, status: response.status }
);
}
```
That repetition is the strongest signal that an `httpRequest` helper belongs
in the core. The slimmed core ships:
```ts
interface HttpRequestInput {
readonly url: string;
readonly fetch: FetchLike;
readonly providerName: string;
readonly init?: RequestInit;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
}
/**
* Issues the request, composes any caller signal with the timeout signal,
* raises CurrencyProviderError(HTTP_ERROR) on non-2xx. Returns the raw
* Response so callers handle .json() / .text() / .arrayBuffer() themselves.
*/
async function httpRequest(input: HttpRequestInput): Promise<Response>;
/**
* Compose two optional AbortSignals into one. Returns:
* - `caller` when only the caller signal is present
* - `timeout` when only the timeout signal is present
* - a combined signal that aborts when EITHER aborts, when both are present
* - `undefined` when neither is present
*/
function composeAbortSignal(
caller: AbortSignal | undefined,
timeout: AbortSignal | undefined
): AbortSignal | undefined;
```
`composeAbortSignal` uses `AbortSignal.any([...])` when available
(Node 20+ / Bun / Chromium 117+) and falls back to a manual
`AbortController` wiring for older runtimes — both code paths are exercised
by tests so the universal-tier guarantee holds.
## Code registry shape
```ts
CURRENCY_PROVIDER_CODES = defineCodes("CURRENCY_PROVIDER", [
// Existing 6 codes (carried forward from the 2026-05-17 archive)
"HTTP_ERROR",
"PARSE_ERROR",
"UPSTREAM_ERROR",
"UNSUPPORTED_BASE",
"MISSING_CONFIG",
"UNKNOWN_CURRENCY",
// New in this refactor (registry-related)
"PROVIDER_NOT_REGISTERED",
"PROVIDER_ALREADY_REGISTERED",
] as const);
```
Both registry codes are reachable from real call sites in this package's
tests (`registry.get("nope")` and double-`register` respectively). The
six pre-existing codes remain reachable through the sibling packages'
tests — this package's own tests no longer trigger HTTP_ERROR / PARSE_ERROR /
etc. because the code paths that raised them are now in the siblings. The
codes themselves stay declared here because (a) `httpRequest` raises
HTTP_ERROR from the shared helper, and (b) every sibling consumes the same
registry and asserting "code is defined here, raised there" is the canonical
foundation-with-plugins shape.
Per AGENTS.md §2c, this is not silent retraction: the codes stay in the
registry; the call sites move to sibling packages where they remain
exercised. The §13 step-0 spec reconciliation will confirm each code is
either raised in this package's `src/` or referenced as a contract that
siblings consume.
## File layout (slim)
```text
src/
├── error-codes/
│ ├── error-codes.ts # 8-code registry + CurrencyProviderError
│ ├── error-codes.md
│ └── error-codes.test.ts
├── types/
│ ├── configs.ts # ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase
│ └── configs.md
├── registry/
│ ├── registry.ts # CurrencyProviderRegistry
│ ├── registry.md
│ └── registry.test.ts
├── factory/
│ ├── factory.ts # defineCurrencyProvider
│ ├── factory.md
│ └── factory.test.ts
├── http/
│ ├── http.ts # httpRequest + composeAbortSignal
│ ├── http.md
│ └── http.test.ts
└── index.ts # explicit named barrel
```
Five feature modules total (down from twelve), matching the §10 no-prefix
rule. Five subpath exports in `package.json` (down from eleven).
## Tier purity
Universal-tier — same as the just-archived shape. No `node:`/`bun:`/DOM
imports anywhere. The shared `httpRequest` helper uses only WHATWG `fetch`,
`AbortController`, and `AbortSignal.any` (with manual fallback). No
`setTimeout`-based timeout: `AbortSignal.timeout(ms)` is the universal
primitive, available in Node 17.3+, Bun, and modern browsers.
## Test strategy
- `registry.test.ts` — register / get / has / list / unregister, both
registry error codes raised, `list()` sort determinism, idempotent-bystander
semantics.
- `factory.test.ts` — `defineCurrencyProvider` returns a frozen object
satisfying `CurrencyProvider`, preserves all four optional flags, forwards
`fetchRates` call-through.
- `http.test.ts` — `httpRequest` happy path, HTTP_ERROR on 5xx, header pass
through, timeout signal abort, caller signal abort, both signals abort,
`composeAbortSignal` for each of the four combinations (neither / caller /
timeout / both).
- `error-codes.test.ts` — every code defined, `SuaveplanError` contract
preserved, all discriminants accessible.
All tests use the `@suaveplan/testing/runner` wrapper and a recording
`FetchLike` closure. No `vi.stubGlobal`, no `vi.mock`.
## Why this is not a breaking-by-design move
The contract every consumer cares about (the `CurrencyProvider` interface,
the `CurrencyProviderError` shape, the eight error codes, the four config
types) is **identical** before and after this refactor. What moves is
*which package owns the concrete classes*. Consumers who installed
`@suaveplan/currency-providers` for the registry / configs / error surface
see the package shrink, not change. Consumers who installed it for the
provider classes need to add the matching `@suaveplan/currency-provider-<name>`
package — a one-line `package.json` change per provider — and update their
import path. The provider classes themselves keep the same constructor
signatures and the same `fetchRates` behaviour, exactly so this move
doesn't compound into a contract change.