Files
suaveplan/openspec/specs/currency-providers/spec.md
2026-05-27 23:29:53 +01:00

326 lines
16 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->