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.
|
||||
206
openspec/archive/2026-05-17-slim-currency-providers/proposal.md
Normal file
206
openspec/archive/2026-05-17-slim-currency-providers/proposal.md
Normal 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.
|
||||
@@ -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 -->
|
||||
102
openspec/archive/2026-05-17-slim-currency-providers/tasks.md
Normal file
102
openspec/archive/2026-05-17-slim-currency-providers/tasks.md
Normal 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/`
|
||||
Reference in New Issue
Block a user