308 lines
12 KiB
Markdown
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.
|