Initial Commit
This commit is contained in:
307
openspec/archive/2026-05-17-slim-currency-providers/design.md
Normal file
307
openspec/archive/2026-05-17-slim-currency-providers/design.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user