Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,206 @@
# Slim @suaveplan/currency-providers to a thin plugin core
**Package**: `@suaveplan/currency-providers`
**Status**: PROPOSED
**Type**: refactor (modification of the just-shipped 2026-05-17 migration)
- **Design**: ./design.md
- **Spec delta**: ./specs/currency-providers/spec.md
## Why
The first shipped shape of `@suaveplan/currency-providers` bundled nine concrete
providers (ECB, Fixer, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap,
CryptoCompare, Kraken, Virtual) into a single npm artefact. That bundling
violates the foundation-with-sibling-plugins shape that `@suaveplan/network`
and `@suaveplan/drive` established for genesis: every concrete plugin is its
own publishable package so consumers install only what they wire in, and each
plugin can iterate on its own version cadence without a coordinated bump of
the umbrella.
Consumers that want only ECB pay the install + tree-shake-graph cost of eight
unused providers. Worse, an upstream API change in (say) CoinMarketCap forces
a rev of the umbrella that every other provider also ships under. Splitting
into `currency-providers` (the contract + registry + shared HTTP helper) and
nine `currency-provider-<name>` siblings (in the follow-up wave) gives
consumers granular installs, gives each provider an independent surface to
evolve, and matches the established `network-plugin-*` / `drive-plugin-*`
shape.
This change is the **slim**: it deletes the nine concrete provider modules
from this package, adds the `CurrencyProviderRegistry` + `defineCurrencyProvider`
factory + `PROVIDER_NOT_REGISTERED` code that the nine sibling packages will
consume, and factors the small HTTP utilities (`httpRequest`,
`composeAbortSignal`) that every provider repeated by hand into a single
shared helper. The nine sibling packages land in the follow-up wave; each
will depend on this slimmed core and re-implement only its own provider.
## What changes
### REMOVED
- `EcbProvider` — moves to `@suaveplan/currency-provider-ecb`.
- `FixerProvider` — moves to `@suaveplan/currency-provider-fixer`.
- `OpenExchangeRatesProvider` — moves to `@suaveplan/currency-provider-open-exchange-rates`.
- `BinanceProvider` — moves to `@suaveplan/currency-provider-binance`.
- `CoinGeckoProvider` + `getCoinGeckoId` — move to `@suaveplan/currency-provider-coingecko`.
- `CoinMarketCapProvider` — moves to `@suaveplan/currency-provider-coinmarketcap`.
- `CryptoCompareProvider` — moves to `@suaveplan/currency-provider-cryptocompare`.
- `KrakenProvider` — moves to `@suaveplan/currency-provider-kraken`.
- `VirtualCurrencyProvider` + `VirtualCurrencyProviderConfig` — move to
`@suaveplan/currency-provider-virtual`.
- All nine `./providers/<name>/<name>` subpath exports drop from
`package.json`.
### ADDED
- `CurrencyProviderRegistry` — in-memory registry of `CurrencyProvider`
instances keyed by `name`. Methods: `register(provider)`, `get(name)`,
`has(name)`, `list()`, `unregister(name)`. `get(unknown)` throws
`CurrencyProviderError(PROVIDER_NOT_REGISTERED)`; `register(duplicate)`
throws `CurrencyProviderError(PROVIDER_ALREADY_REGISTERED)`.
- `defineCurrencyProvider(definition)` — factory that returns a stable-shape
`CurrencyProvider`. Lets sibling packages declare a provider as a plain
object literal without re-implementing the class boilerplate.
- `httpRequest()` shared helper — issues a request via the injected
`FetchLike`, applies `HttpFetchOptions.timeoutMs` and `headers`, composes
the caller's `AbortSignal` with the timeout signal, throws
`CurrencyProviderError(HTTP_ERROR)` on non-2xx with `status` populated.
- `composeAbortSignal()` shared helper — merges an optional caller signal
with an optional timeout-derived signal into a single `AbortSignal`.
- `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` codes added to
`CURRENCY_PROVIDER_CODES`.
### KEPT (unchanged)
- `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`, `ProviderConfigBase`
shared config types — every sibling provider extends these.
- `CURRENCY_PROVIDER_CODES` registry (now 8 codes: original 6 plus the 2
registry codes) + `CurrencyProviderError` class with
`providerName` / `status` / `upstreamMessage` discriminants.
- Universal-tier purity, `sideEffects: false`, explicit named barrels,
`@suaveplan/testing/runner` test imports, 100/100/100/100 coverage gate.
## Source mapping
This refactor is a MODIFICATION of `openspec/archive/2026-05-17-add-currency-providers/`.
The provider implementations now slated for removal are at:
| Removed module | Source in just-shipped archive |
|---|---|
| `EcbProvider` | `src/providers/ecb/ecb.ts` L1-L111 (per archived spec L84-L122) |
| `FixerProvider` | `src/providers/fixer/fixer.ts` (per archived spec L126-L158) |
| `OpenExchangeRatesProvider` | `src/providers/open-exchange-rates/open-exchange-rates.ts` (per archived spec L162-L187) |
| `BinanceProvider` | `src/providers/binance/binance.ts` (per archived spec L191-L217) |
| `CoinGeckoProvider` + `getCoinGeckoId` | `src/providers/coingecko/coingecko.ts` (per archived spec L221-L249) |
| `CoinMarketCapProvider` | `src/providers/coinmarketcap/coinmarketcap.ts` (per archived spec L253-L276) |
| `CryptoCompareProvider` | `src/providers/cryptocompare/cryptocompare.ts` (per archived spec L280-L299) |
| `KrakenProvider` | `src/providers/kraken/kraken.ts` (per archived spec L303-L325) |
| `VirtualCurrencyProvider` | `src/providers/virtual/virtual.ts` (per archived spec L329-L375) |
The new registry / factory / HTTP helper surface is genesis-original, modelled
on the established foundation-with-sibling-plugins shapes:
| New module | Reference shape |
|---|---|
| `CurrencyProviderRegistry` | `packages/network/network/src/manager/` `PluginManager` |
| `defineCurrencyProvider` | `packages/drive/drive/src/plugin/` factory pattern |
| `httpRequest` + `composeAbortSignal` | extracted from the duplicated `fetchImpl(url); if (!response.ok) throw HTTP_ERROR` block present in every provider being removed |
The enterprise audit reference is `../suaveplan/enterprise/currency-providers.md`;
the slim shape was not present upstream — it is a genesis-native architectural
correction.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/currency-providers` to enumerate.
At time of writing there are no current dependents (the package shipped on
2026-05-17 and the commerce stack downstream is still pending migration):
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-providers`).
The future wave of sibling provider packages will all depend on this slimmed
core. Each is listed here so the next reviewer can see the blast radius the
follow-up wave will land in:
- `@suaveplan/currency-provider-ecb` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-fixer` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-open-exchange-rates` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-binance` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-coingecko` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-coinmarketcap` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-cryptocompare` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-kraken` — [x] consumes registry / factory / httpRequest / configs / error
- `@suaveplan/currency-provider-virtual` — [x] consumes registry / factory / configs / error (no HTTP)
- `@suaveplan/currency` — [ ] tests run / [x] docs reviewed / [x] no action needed (the `CurrencyProvider` contract lives in `@suaveplan/currency` and is unchanged by this refactor; the slim core depends on it, not the other way around)
## Migration
The nine concrete provider classes are gone from `@suaveplan/currency-providers`
and now live in dedicated sibling packages. Consumers must install the
specific provider package they need and either instantiate it directly or
register it with the new `CurrencyProviderRegistry`.
### Before
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider, CoinGeckoProvider } from "@suaveplan/currency-providers";
const converter = new CurrencyConverter({
provider: new EcbProvider(),
fallbackProviders: [new CoinGeckoProvider()],
});
```
### After
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
registry.register(new CoinGeckoProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
fallbackProviders: [registry.get("coingecko")],
});
```
Direct instantiation without the registry is equally supported — the registry
is a convenience for callers that wire many providers and want a single
lookup surface. Sibling packages remain `CurrencyProvider`-compatible so the
old `new EcbProvider()` form still works once the sibling package is installed.
Consumers should run this search command to find call sites that need to add
sibling-package imports:
```bash
grep -rln "from ['\"]@suaveplan/currency-providers['\"]" \
packages/ apps/ src/ 2>/dev/null | grep -v "/dist/\|/coverage/"
```
Every hit that uses one of `EcbProvider`, `FixerProvider`,
`OpenExchangeRatesProvider`, `BinanceProvider`, `CoinGeckoProvider`,
`CoinMarketCapProvider`, `CryptoCompareProvider`, `KrakenProvider`,
`VirtualCurrencyProvider`, `VirtualCurrencyProviderConfig`, or
`getCoinGeckoId` needs its import rewritten to the matching
`@suaveplan/currency-provider-<name>` package.
## Out of scope
- The nine sibling `@suaveplan/currency-provider-<name>` packages — each
ships under its own OpenSpec change in the follow-up wave per AGENTS.md
§0c "Multi-package / cross-cutting feature sets" (one change per package;
cross-cutting design lives once, in this package's `design.md`).
- Rate-limit / retry / historical-rate / WebSocket / on-chain oracle /
aggregator / health-monitor / OTel surfaces — same deferral as the
2026-05-17 archive's `## Out of scope`. Nothing in this slim refactor
changes those plans; once those wrappers land they consume the
`CurrencyProvider` contract through the registry exactly the same way.

View File

@@ -0,0 +1,358 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-providers (slim-currency-providers)
**Package**: `@suaveplan/currency-providers`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> No exceptions. This delta MODIFIES the canonical spec landed by the
> 2026-05-17 `add-currency-providers` change.
## REMOVED Requirements
### Requirement: EcbProvider — ECB daily XML feed
**Reason**: Moves to sibling package `@suaveplan/currency-provider-ecb` per
the foundation-with-sibling-plugins refactor. The contract is unchanged;
only the package boundary moves. See `./design.md` for the shape.
---
### Requirement: FixerProvider — Fixer.io with API key
**Reason**: Moves to sibling package `@suaveplan/currency-provider-fixer`.
---
### Requirement: OpenExchangeRatesProvider — OXR with API key
**Reason**: Moves to sibling package `@suaveplan/currency-provider-open-exchange-rates`.
---
### Requirement: BinanceProvider — Binance spot ticker
**Reason**: Moves to sibling package `@suaveplan/currency-provider-binance`.
---
### Requirement: CoinGeckoProvider — simple/price endpoint + getCoinGeckoId
**Reason**: Moves to sibling package `@suaveplan/currency-provider-coingecko`.
---
### Requirement: CoinMarketCapProvider — quotes/latest with API key header
**Reason**: Moves to sibling package `@suaveplan/currency-provider-coinmarketcap`.
---
### Requirement: CryptoCompareProvider — pricemulti
**Reason**: Moves to sibling package `@suaveplan/currency-provider-cryptocompare`.
---
### Requirement: KrakenProvider — Ticker with pair→symbol reverse mapping
**Reason**: Moves to sibling package `@suaveplan/currency-provider-kraken`.
---
### Requirement: VirtualCurrencyProvider — operator-defined currencies
**Reason**: Moves to sibling package `@suaveplan/currency-provider-virtual`
(retains its `@suaveplan/money` `registerCurrency` integration there).
## MODIFIED Requirements
### 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: 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"`
## ADDED Requirements
### 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 add
`PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` so the new
`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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,102 @@
# Tasks — Slim @suaveplan/currency-providers
## Spec-completeness preflight (AGENTS.md §12a)
- [x] Numbered checklist of every `### Requirement` and `#### Scenario`
drafted in `specs/currency-providers/spec.md` (10 requirements
kept/added; see `./specs/currency-providers/spec.md`).
## Remove concrete provider modules
- [x] Delete `src/providers/ecb/`
- [x] Delete `src/providers/fixer/`
- [x] Delete `src/providers/open-exchange-rates/`
- [x] Delete `src/providers/binance/`
- [x] Delete `src/providers/coingecko/`
- [x] Delete `src/providers/coinmarketcap/`
- [x] Delete `src/providers/cryptocompare/`
- [x] Delete `src/providers/kraken/`
- [x] Delete `src/providers/virtual/`
- [x] Delete now-empty `src/providers/` directory
- [x] Drop the 9 `./providers/<name>/<name>` subpath exports from
`package.json`
- [x] Drop `@suaveplan/currency` and `@suaveplan/money` from `dependencies`
iff no remaining surface needs them — actually `@suaveplan/currency`
stays (registry stores `CurrencyProvider` instances from that pkg)
and `@suaveplan/money` drops (only the deleted `VirtualCurrencyProvider`
used `registerCurrency`).
## Add slim-core surface
- [x] Add `PROVIDER_NOT_REGISTERED` + `PROVIDER_ALREADY_REGISTERED` codes to
`CURRENCY_PROVIDER_CODES` (`src/error-codes/error-codes.ts`)
- [x] Build `src/registry/registry.ts` — `CurrencyProviderRegistry` class with
`register` / `get` / `has` / `list` / `unregister`
- [x] Build `src/registry/registry.md` (sibling doc, ≥200 words)
- [x] Build `src/registry/registry.test.ts` — covers every method, both
registry error codes, `list()` sort determinism
- [x] Build `src/factory/factory.ts` — `defineCurrencyProvider` factory
- [x] Build `src/factory/factory.md`
- [x] Build `src/factory/factory.test.ts`
- [x] Build `src/http/http.ts` — `httpRequest` + `composeAbortSignal`
- [x] Build `src/http/http.md`
- [x] Build `src/http/http.test.ts` — happy path, HTTP_ERROR on non-2xx,
header forwarding, caller-signal abort, timeout-signal abort, both
signals abort, `composeAbortSignal` all four combinations
- [x] Update `src/error-codes/error-codes.test.ts` to assert the two new
codes and prove they are reachable from registry call sites
- [x] Update `src/index.ts` — explicit named barrel with new exports + drop
removed provider exports
- [x] Update `package.json` `exports` map: 5 subpaths total (`.`,
`./error-codes/error-codes`, `./types/configs`, `./registry/registry`,
`./factory/factory`, `./http/http`)
- [x] Update `tests/dep-graph.test.ts` to match the new subpath list (or
delete the per-subpath assertions and replace with a check that
every `<feature>/index.ts` is referenced from a subpath export)
- [x] Update `vite.config.ts` external list (drop `@suaveplan/money`)
## Documentation
- [x] Update `README.md` Modules + Provider summary sections to describe the
slim core (registry / factory / httpRequest / shared types) and link
to the future sibling packages
- [x] Confirm every `.ts` in `src/` has a sibling `.md` (the §13 step-5
grep returns zero)
## Pre-archive gates (AGENTS.md §13)
- [ ] Step 0 — spec reconciliation: every `### Requirement` and
`**Verified by:**` resolves to a real file + verbatim test name
- [ ] Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/currency-providers`
and `bun run check` both green
- [ ] Step 2 — `bunx turbo test:coverage --filter=@suaveplan/currency-providers`
reports 100/100/100/100 via istanbul
- [ ] Step 3 — `bunx turbo build --filter=@suaveplan/currency-providers` green
- [ ] Step 4 — banned-pattern grep returns zero
- [ ] Step 4a — barrel-shape grep returns zero
- [ ] Step 4b — test-runner-import grep returns zero
- [ ] Step 5 — missing-`.md` grep returns zero
- [ ] Step 6 — README present
- [ ] Step 7 — bare-version grep returns zero
- [ ] Step 8 — subpath-export count = 5 (matches feature module count)
- [ ] Step 9 — security-scan grep returns zero
- [ ] Step 10 — dependent sweep
(`bunx turbo test --filter=...@suaveplan/currency-providers`)
## Root-doc refresh (AGENTS.md §13b)
- [ ] Re-run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
`MANIFEST.md`
- [ ] Re-run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`
- [ ] Update `STATUS.md` row for `@suaveplan/currency-providers` to describe
the slim shape
- [ ] Update `SUMMARY.md` row for `@suaveplan/currency-providers` likewise
- [ ] `bunx markdownlint-cli2 --fix` across touched root + change files
## Archive (AGENTS.md §14)
- [ ] Fold `specs/currency-providers/spec.md` into
`openspec/specs/currency-providers/spec.md` (apply REMOVED + ADDED
to the canonical spec landed by the 2026-05-17 change)
- [ ] Move `openspec/changes/slim-currency-providers/` →
`openspec/archive/2026-05-17-slim-currency-providers/`