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

12 KiB

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

@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

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

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:

// 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:

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:

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

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)

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.