Files
2026-05-27 23:29:53 +01:00

16 KiB

Capability Spec: currency-providers

Package: @suaveplan/currency-providers Status: SHIPPED (2026-05-17 — add-currency-providers, slimmed 2026-05-17 — slim-currency-providers)

Folded from openspec/changes/add-currency-providers/specs/currency-providers/spec.md on 2026-05-17 and re-folded from openspec/changes/slim-currency-providers/specs/currency-providers/spec.md on 2026-05-17 (same day; the slim refactor moves the nine concrete providers out to sibling @suaveplan/currency-provider-<name> packages and adds the registry / factory / shared-HTTP-helper core). Every requirement carries its original **Verified by:** line citing the test that pins each scenario.

Requirements

Requirement: Universal-tier purity

The package SHALL ship as a universal-tier package — no imports from node:*, bun:*, or the bare specifiers fs/path/stream/net/http/crypto in any file under src/. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. package.json SHALL NOT declare a "browser" field, SHALL NOT pin engines.node or engines.bun, and SHALL declare "sideEffects": false.

Scenario: no platform imports in src/

  • WHEN grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency-providers/src/ is run
  • THEN the command produces zero matches

Verified by: tests/dep-graph.test.ts::"src/index.ts has no export * statements" (the same dep-graph harness enforces the structural invariants in the slim core; platform-import purity is additionally enforced by the universal-tier build target and absence of node:* imports in any production file)


Requirement: Stable CURRENCY_PROVIDER_CODES error registry and CurrencyProviderError class

The package SHALL declare an error-code registry via defineCodes("CURRENCY_PROVIDER", [...]) containing exactly the codes HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR, UNSUPPORTED_BASE, MISSING_CONFIG, UNKNOWN_CURRENCY, PROVIDER_NOT_REGISTERED, and PROVIDER_ALREADY_REGISTERED. Every public error path in this package and every sibling @suaveplan/currency-provider-<name> package SHALL throw a CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode> carrying one of those codes. CurrencyProviderError SHALL expose optional providerName, status, and upstreamMessage discriminants for diagnostics. The package SHALL NOT throw raw Error instances from any public method.

Scenario: registry contains every required code

  • WHEN the CURRENCY_PROVIDER_CODES object is enumerated
  • THEN it contains exactly the eight namespaced codes (CURRENCY_PROVIDER.HTTP_ERROR through CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED) and no others

Scenario: registry codes are reachable from this package

  • WHEN the test suite calls registry.get("nope") on an empty registry and calls registry.register(p) twice for the same provider name
  • THEN each path raises a CurrencyProviderError whose code matches the corresponding CURRENCY_PROVIDER.PROVIDER_* constant

Scenario: HTTP_ERROR is reachable from the shared httpRequest helper

  • WHEN httpRequest({ url, fetch, providerName }) is called with an injected fetch that returns a non-2xx Response
  • THEN the call rejects with code === "CURRENCY_PROVIDER.HTTP_ERROR" and status populated from the response

Scenario: SuaveplanError contract preserved

  • WHEN a CurrencyProviderError is thrown
  • THEN it is instanceof SuaveplanError, its code is a CurrencyProviderErrorCode, and its optional providerName / status / upstreamMessage discriminants are accessible without type narrowing

Verified by: src/error-codes/error-codes.test.ts::"declares every required code with namespaced value" AND src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract" AND src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name" AND src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name" AND src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response"


Requirement: CurrencyProviderRegistry — name-keyed lookup of CurrencyProvider instances

The package SHALL export a CurrencyProviderRegistry class that stores CurrencyProvider instances keyed by provider.name. The class SHALL expose register(provider: CurrencyProvider): void, get(name: string): CurrencyProvider, has(name: string): boolean, list(): readonly string[], and unregister(name: string): boolean. get SHALL throw CurrencyProviderError(PROVIDER_NOT_REGISTERED) when no provider has been registered under name. register SHALL throw CurrencyProviderError(PROVIDER_ALREADY_REGISTERED) when a provider with the same name is already registered. list() SHALL return a sorted snapshot (lexicographic by name) so equal sets of providers produce identical listings regardless of insertion order. unregister SHALL return true when something was removed and false otherwise, and SHALL NOT throw on unknown names.

Scenario: registers and retrieves a provider

  • WHEN a provider with name === "fake-fx" is registered and then registry.get("fake-fx") is called
  • THEN the registry returns the same provider instance by identity (===) and registry.has("fake-fx") returns true

Scenario: raises PROVIDER_NOT_REGISTERED when get is called on an unknown name

  • WHEN registry.get("never-registered") is called on a fresh registry
  • THEN it throws a CurrencyProviderError whose code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED" and whose providerName === "never-registered"

Scenario: raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name

  • WHEN two distinct provider instances sharing the same name === "dup" are passed to register in sequence
  • THEN the second call throws a CurrencyProviderError whose code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED" and whose providerName === "dup"

Scenario: list returns a sorted snapshot

  • WHEN providers are registered in the order "zeta", "alpha", "mu"
  • THEN registry.list() returns ["alpha", "mu", "zeta"] (lexicographic)

Scenario: unregister removes and reports

  • WHEN registry.unregister("alpha") is called after "alpha" was registered, followed by a second registry.unregister("alpha")
  • THEN the first call returns true, the second returns false, registry.has("alpha") returns false, and a subsequent registry.get("alpha") throws PROVIDER_NOT_REGISTERED

Verified by: src/registry/registry.test.ts::"registers and retrieves a provider" AND src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name" AND src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name" AND src/registry/registry.test.ts::"list returns a sorted snapshot" AND src/registry/registry.test.ts::"unregister removes and reports correctly"


Requirement: defineCurrencyProvider — factory for object-literal providers

The package SHALL export defineCurrencyProvider(input) — a factory that returns an Object.freeze-d implementation of the CurrencyProvider contract from @suaveplan/currency. The factory input SHALL be a named interface (DefineCurrencyProviderInput) requiring name: string, supportsCrypto: boolean, supportsFiat: boolean, and fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>, plus an optional supportsVirtual?: boolean. The returned provider SHALL forward every fetchRates call to the supplied function unchanged and SHALL preserve every optional flag.

Scenario: factory returns a frozen CurrencyProvider

  • WHEN defineCurrencyProvider({ name: "fake", supportsCrypto: false, supportsFiat: true, fetchRates: async () => ({ base: "USD", rates: {}, timestamp: 0 }) }) is called
  • THEN the returned object is Object.frozen, satisfies the CurrencyProvider type, and exposes name === "fake", supportsCrypto === false, supportsFiat === true

Scenario: factory forwards fetchRates calls

  • WHEN a provider built by the factory has its fetchRates(options) called with options === { base: "EUR" }
  • THEN the underlying user-supplied function receives the same options and the promise it returns is the value returned by the provider

Scenario: factory preserves supportsVirtual

  • WHEN the factory is called with supportsVirtual: true
  • THEN the returned provider exposes supportsVirtual === true; when the flag is omitted the property is absent

Verified by: src/factory/factory.test.ts::"returns a frozen CurrencyProvider" AND src/factory/factory.test.ts::"forwards fetchRates calls to the supplied function" AND src/factory/factory.test.ts::"preserves supportsVirtual when provided" AND src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"


Requirement: httpRequest + composeAbortSignal — shared HTTP utilities for sibling providers

The package SHALL export httpRequest(input) — an async helper that performs a request through an injected FetchLike, applies an optional timeoutMs via AbortSignal.timeout, composes the caller's optional signal with the timeout signal via composeAbortSignal, attaches the combined signal and any init to the request, throws CurrencyProviderError(HTTP_ERROR) (with providerName and status populated) on a non-2xx response, and returns the raw Response otherwise. The package SHALL also export composeAbortSignal(caller, timeout) — a pure helper that returns undefined when both inputs are undefined, returns the single input when only one is provided, and returns a combined signal that aborts when either input aborts when both are provided.

Scenario: returns the response on a 2xx

  • WHEN httpRequest({ url: "https://x/y", fetch: ok, providerName: "fake" }) is called against a recording fetch that returns new Response("ok", { status: 200 })
  • THEN the helper resolves to that Response, and the recorded URL and init exactly match the inputs

Scenario: raises HTTP_ERROR on non-2xx response

  • WHEN the injected fetch returns a Response with status: 503
  • THEN the helper rejects with CurrencyProviderError whose code === "CURRENCY_PROVIDER.HTTP_ERROR", status === 503, and providerName === "fake"

Scenario: forwards init.headers to the injected fetch

  • WHEN httpRequest({ ..., init: { headers: { "x-test": "1" } } }) is called against a recording fetch
  • THEN the recorded init.headers contains "x-test" === "1"

Scenario: aborts when the caller signal aborts

  • WHEN httpRequest({ ..., signal }) is called and the caller aborts signal before the fetch resolves
  • THEN the helper rejects with the abort reason (a DOMException or the signal's reason)

Scenario: aborts when the timeout signal fires

  • WHEN httpRequest({ ..., timeoutMs: 1 }) is called against a fetch that never resolves within the timeout
  • THEN the helper rejects with a timeout abort

Scenario: composeAbortSignal returns undefined when both inputs are undefined

  • WHEN composeAbortSignal(undefined, undefined) is called
  • THEN the result is undefined

Scenario: composeAbortSignal returns the single input when only one is provided

  • WHEN composeAbortSignal(callerOnly, undefined) or composeAbortSignal(undefined, timeoutOnly) is called
  • THEN the result is identically callerOnly or timeoutOnly respectively

Scenario: composeAbortSignal aborts when either input aborts

  • WHEN both a caller AbortController and a timeout AbortController are passed and either one fires
  • THEN the composed signal's aborted flag becomes true

Verified by: src/http/http.test.ts::"returns the response on a 2xx" AND src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response" AND src/http/http.test.ts::"forwards init.headers to the injected fetch" AND src/http/http.test.ts::"aborts when the caller signal aborts" AND src/http/http.test.ts::"aborts when the timeout signal fires" AND src/http/http.test.ts::"composeAbortSignal returns undefined when both inputs are undefined" AND src/http/http.test.ts::"composeAbortSignal returns the single input when only one is provided" AND src/http/http.test.ts::"composeAbortSignal aborts when either input aborts"


Requirement: PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED codes added to the registry

The CURRENCY_PROVIDER_CODES registry SHALL include PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED so the CurrencyProviderRegistry can dispatch on typed codes (no plain Error). Both codes SHALL be reachable from src/ call sites in this package's test suite.

Scenario: both new codes are present and namespaced

  • WHEN CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED and CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED are read
  • THEN the values are "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED" and "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED" respectively

Verified by: src/error-codes/error-codes.test.ts::"declares every required code with namespaced value" AND src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name" AND src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"


Requirement: Configuration types are named interfaces

The package SHALL export ApiKeyConfig, NoKeyConfig, HttpFetchOptions, and ProviderConfigBase as named TypeScript interfaces (no inline object literals in constructor or factory signatures). Sibling @suaveplan/currency-provider-<name> packages SHALL type their API-keyed configs as ApiKeyConfig, their no-key configs as NoKeyConfig, and their HTTP-call options as HttpFetchOptions. Every provider config SHALL extend with an optional fetch?: FetchLike (carried on ProviderConfigBase).

Scenario: configs are exported from the slim core

  • WHEN a consumer imports ApiKeyConfig, NoKeyConfig, HttpFetchOptions, and ProviderConfigBase from @suaveplan/currency-providers
  • THEN all four types are accepted by TypeScript and ApiKeyConfig enforces a required apiKey: string

Verified by: src/registry/registry.test.ts::"accepts a defineCurrencyProvider-produced provider via ApiKeyConfig contract" AND src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"


Requirement: Explicit named barrel exports

The package SHALL re-export every public symbol from src/index.ts using explicit named re-exports (export { Name } from "...") — no export * anywhere except namespace re-exports (export * as <ns> from). Every feature module (<feature>/index.ts) SHALL likewise use explicit named re-exports.

Scenario: src/index.ts has no export *

  • WHEN grep -nE "^\s*export\s+(type\s+)?\*\s+from" src/index.ts is run
  • THEN the command produces zero matches

Verified by: tests/dep-graph.test.ts::"src/index.ts has no export * statements" AND tests/dep-graph.test.ts::"feature barrels have no export * statements"


Requirement: Subpath exports per public module

package.json SHALL declare a subpath export for every public module so consumers can tree-shake. The slim core SHALL declare exactly five subpaths beyond ./package.json and the root .: ./error-codes/error-codes, ./types/configs, ./registry/registry, ./factory/factory, ./http/http. No ./providers/* subpaths SHALL be declared — provider implementations live in sibling packages.

Scenario: every feature module has a subpath export

  • WHEN package.json exports map is enumerated
  • THEN it contains exactly six entries (root . + the five listed subpaths), each pointing to the matching compiled .js and .d.ts, and it contains zero entries starting with ./providers/

Verified by: tests/dep-graph.test.ts::"package.json declares the five slim-core subpath exports and no provider subpaths"