Initial Commit
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-providers (add-currency-providers)
|
||||
|
||||
**Package**: `@suaveplan/currency-providers`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`,
|
||||
`bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto`
|
||||
in any file under `src/`. The package SHALL run unchanged under Node ≥ 18,
|
||||
Bun, modern browsers, and Web Workers. `package.json` SHALL NOT declare a
|
||||
`"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL
|
||||
declare `"sideEffects": false`.
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency-providers/src/` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"is universal-tier (no node/bun imports)"`
|
||||
|
||||
---
|
||||
|
||||
### 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`. Every public error path 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: every code is reachable from a call site
|
||||
|
||||
- **WHEN** the test suite exercises an HTTP 5xx response, a malformed
|
||||
upstream payload, an upstream-flagged failure (Fixer `success:false`,
|
||||
Kraken `error[]` non-empty), an ECB base it does not quote, a
|
||||
config without an `apiKey` where one is required, and a virtual
|
||||
currency lookup for a code that has not been registered
|
||||
- **THEN** each path raises a `CurrencyProviderError` whose `code` matches
|
||||
the expected `CURRENCY_PROVIDER.*` constant
|
||||
|
||||
#### 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::"every CURRENCY_PROVIDER code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Injectable FetchLike on every provider
|
||||
|
||||
Every provider SHALL accept an optional `fetch: FetchLike` (from
|
||||
`@suaveplan/types`) in its constructor config and SHALL use it for every
|
||||
HTTP call. When not supplied, the provider SHALL default to
|
||||
`globalThis.fetch`. Tests SHALL exercise providers by injecting a real
|
||||
`FetchLike` closure that returns a synthesised WHATWG `Response` — no
|
||||
package SHALL mock `globalThis.fetch` via `vi.stubGlobal` or `vi.mock`.
|
||||
|
||||
#### Scenario: provider uses injected fetch
|
||||
|
||||
- **WHEN** a provider is constructed with a custom `fetch` closure that
|
||||
records the URL it was called with
|
||||
- **THEN** `fetchRates(...)` calls the injected closure (not
|
||||
`globalThis.fetch`), and the recorded URL matches the provider's expected
|
||||
endpoint
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"uses injected fetch closure"` AND `src/providers/binance/binance.test.ts::"uses injected fetch closure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EcbProvider — ECB daily XML feed
|
||||
|
||||
The package SHALL export `EcbProvider implements CurrencyProvider` with
|
||||
`name === "ecb"`, `supportsCrypto === false`, `supportsFiat === true`. It
|
||||
SHALL fetch from
|
||||
`https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml` (overridable
|
||||
via `config.baseUrl`), parse the XML rate envelope, return EUR-based rates
|
||||
by default, and convert to any other base by dividing through the EUR rate
|
||||
when a `base` other than EUR is requested. An HTTP non-2xx response SHALL
|
||||
raise `CURRENCY_PROVIDER.HTTP_ERROR`; a request for a base ECB does not
|
||||
quote SHALL raise `CURRENCY_PROVIDER.UNSUPPORTED_BASE`. Calls with a
|
||||
`targets` array SHALL filter the returned rate map to only those codes.
|
||||
|
||||
#### Scenario: parses EUR-base XML and filters targets
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called
|
||||
and the injected `fetch` returns the canonical ECB envelope
|
||||
- **THEN** the result has `base === "EUR"`, exactly two rates (`USD`,
|
||||
`GBP`) numerically matching the envelope, and a positive `timestamp`
|
||||
|
||||
#### Scenario: converts to non-EUR base
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against the same envelope
|
||||
- **THEN** `result.base === "USD"` and `result.rates.EUR` equals `1 / USD_rate`
|
||||
|
||||
#### Scenario: HTTP error becomes CURRENCY_PROVIDER.HTTP_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns a non-2xx Response
|
||||
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
#### Scenario: unsupported base raises UNSUPPORTED_BASE
|
||||
|
||||
- **WHEN** `fetchRates({ base: "XYZ" })` is called against the canonical
|
||||
envelope (XYZ not present)
|
||||
- **THEN** it rejects with a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"parses EUR-base XML and filters targets"` AND `src/providers/ecb/ecb.test.ts::"converts to non-EUR base"` AND `src/providers/ecb/ecb.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/providers/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE for missing currency"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FixerProvider — Fixer.io with API key
|
||||
|
||||
The package SHALL export `FixerProvider implements CurrencyProvider` with
|
||||
`name === "fixer"`, `supportsCrypto === false`, `supportsFiat === true`. It
|
||||
SHALL require `ApiKeyConfig.apiKey` (constructor raises
|
||||
`CURRENCY_PROVIDER.MISSING_CONFIG` if absent), call
|
||||
`<baseUrl>/latest?access_key=<key>&base=<base>&symbols=<targets>` (symbols
|
||||
only when `targets` is provided), and return `{ base, rates, timestamp }`
|
||||
where `timestamp` is the upstream `timestamp * 1000`. An upstream
|
||||
`success === false` SHALL raise `CURRENCY_PROVIDER.UPSTREAM_ERROR` carrying
|
||||
`upstreamMessage` from the response's `error.info`.
|
||||
|
||||
#### Scenario: builds correct URL and returns rates
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called
|
||||
with a successful payload
|
||||
- **THEN** the recorded URL contains `access_key=`, `base=EUR`, and
|
||||
`symbols=USD,GBP`, and the result is the upstream `rates` with
|
||||
`timestamp` in ms
|
||||
|
||||
#### Scenario: upstream success:false becomes UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the upstream returns 200 OK with `{ success: false, error: { info: "invalid key" } }`
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
and `upstreamMessage === "invalid key"`
|
||||
|
||||
#### Scenario: missing apiKey raises MISSING_CONFIG
|
||||
|
||||
- **WHEN** `new FixerProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `CurrencyProviderError` with
|
||||
`code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
**Verified by:** `src/providers/fixer/fixer.test.ts::"builds URL and returns rates"` AND `src/providers/fixer/fixer.test.ts::"raises UPSTREAM_ERROR on success:false"` AND `src/providers/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenExchangeRatesProvider — OXR with API key
|
||||
|
||||
The package SHALL export `OpenExchangeRatesProvider implements CurrencyProvider`
|
||||
with `name === "open-exchange-rates"`, `supportsCrypto === false`,
|
||||
`supportsFiat === true`. It SHALL require `ApiKeyConfig.apiKey`, call
|
||||
`<baseUrl>/latest.json?app_id=<key>&base=<base>`, filter by `targets` when
|
||||
provided, and convert upstream `timestamp` (seconds) to ms.
|
||||
|
||||
#### Scenario: builds URL with API key and base
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["EUR"] })` is called
|
||||
- **THEN** the recorded URL contains `app_id=`, `base=USD`, and the result
|
||||
has exactly one rate `EUR` and the upstream timestamp converted to ms
|
||||
|
||||
#### Scenario: raises MISSING_CONFIG when apiKey absent
|
||||
|
||||
- **WHEN** `new OpenExchangeRatesProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
#### Scenario: HTTP error becomes HTTP_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns 401 Unauthorized
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
and `status === 401`
|
||||
|
||||
**Verified by:** `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"builds URL with API key and filters targets"` AND `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises MISSING_CONFIG when apiKey absent"` AND `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises HTTP_ERROR on 401"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider — Binance spot ticker
|
||||
|
||||
The package SHALL export `BinanceProvider implements CurrencyProvider` with
|
||||
`name === "binance"`, `supportsCrypto === true`, `supportsFiat === false`,
|
||||
defaulting to USDT base. It SHALL call `<baseUrl>/ticker/price`, treat each
|
||||
returned `{symbol, price}` as a pair ending in the requested base, strip the
|
||||
suffix to derive the canonical symbol, skip zero-price tickers, and invert
|
||||
the price (`1/price`). `targets` SHALL filter the resulting map.
|
||||
|
||||
#### Scenario: parses tickers and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USDT" })` is called against a synthesised
|
||||
ticker list containing `BTCUSDT`, `ETHUSDT`, `BTCEUR`
|
||||
- **THEN** result contains `BTC` and `ETH` (USDT-suffixed) with
|
||||
`1 / price`, and does not contain `BTCEUR`
|
||||
|
||||
#### Scenario: skips zero-price tickers
|
||||
|
||||
- **WHEN** the ticker list contains a `FAKUSDT` entry with `price === "0"`
|
||||
- **THEN** the result does not contain a `FAK` key
|
||||
|
||||
#### Scenario: raises HTTP_ERROR on non-2xx
|
||||
|
||||
- **WHEN** the injected fetch returns 503
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
**Verified by:** `src/providers/binance/binance.test.ts::"parses tickers with USDT base and inverts prices"` AND `src/providers/binance/binance.test.ts::"skips zero-price tickers"` AND `src/providers/binance/binance.test.ts::"raises HTTP_ERROR on non-2xx response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CoinGeckoProvider — simple/price endpoint + getCoinGeckoId
|
||||
|
||||
The package SHALL export `CoinGeckoProvider implements CurrencyProvider`
|
||||
with `name === "coingecko"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`, defaulting to USD base. It SHALL call
|
||||
`<baseUrl>/simple/price?ids=<coin-ids>&vs_currencies=<lower-base>`, map the
|
||||
returned CoinGecko ids back to canonical symbols using its built-in
|
||||
registry, invert the price (`1/price`), filter by `targets`, and return
|
||||
`{ base, rates, timestamp }`. The package SHALL also export
|
||||
`getCoinGeckoId(symbol: string): string | undefined` — a case-insensitive
|
||||
lookup against the same built-in registry.
|
||||
|
||||
#### Scenario: maps coin ids back to symbols and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against a payload
|
||||
containing `bitcoin`, `ethereum`, `solana`
|
||||
- **THEN** result contains `BTC`, `ETH`, `SOL` with `1 / price` each
|
||||
|
||||
#### Scenario: getCoinGeckoId is case-insensitive
|
||||
|
||||
- **WHEN** `getCoinGeckoId("btc")` and `getCoinGeckoId("BTC")` are called
|
||||
- **THEN** both return `"bitcoin"`
|
||||
|
||||
#### Scenario: unknown symbol returns undefined
|
||||
|
||||
- **WHEN** `getCoinGeckoId("ZZZNOPE")` is called
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
**Verified by:** `src/providers/coingecko/coingecko.test.ts::"maps coin ids back to symbols and inverts prices"` AND `src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId is case-insensitive"` AND `src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId returns undefined for unknown symbol"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CoinMarketCapProvider — quotes/latest with API key header
|
||||
|
||||
The package SHALL export `CoinMarketCapProvider implements CurrencyProvider`
|
||||
with `name === "coinmarketcap"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`. It SHALL require `ApiKeyConfig.apiKey`
|
||||
(`CURRENCY_PROVIDER.MISSING_CONFIG` if absent), call
|
||||
`<baseUrl>/cryptocurrency/quotes/latest?symbol=<symbols>&convert=<base>`
|
||||
with `X-CMC_PRO_API_KEY` and `Accept: application/json` request headers,
|
||||
invert the price (`1/price`), and return `{ base, rates, timestamp }`.
|
||||
`targets` SHALL replace the default symbol list when provided.
|
||||
|
||||
#### Scenario: sends API key header and parses quotes
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC", "ETH"] })` is called
|
||||
and the injected fetch records the request init
|
||||
- **THEN** the recorded `init.headers` contains `X-CMC_PRO_API_KEY` and the
|
||||
result has `BTC` and `ETH` with `1 / quote.USD.price`
|
||||
|
||||
#### Scenario: missing apiKey raises MISSING_CONFIG
|
||||
|
||||
- **WHEN** `new CoinMarketCapProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
**Verified by:** `src/providers/coinmarketcap/coinmarketcap.test.ts::"sends API key header and parses quotes"` AND `src/providers/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CryptoCompareProvider — pricemulti
|
||||
|
||||
The package SHALL export `CryptoCompareProvider implements CurrencyProvider`
|
||||
with `name === "cryptocompare"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`, defaulting to USD base. It SHALL call
|
||||
`<baseUrl>/pricemulti?fsyms=<targets-or-defaults>&tsyms=<base>`, invert the
|
||||
price (`1/price`), and skip entries whose price is `<= 0`.
|
||||
|
||||
#### Scenario: inverts pricemulti payload and skips zero prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC", "FAKE"] })` is called
|
||||
against `{ BTC: { USD: 67000 }, FAKE: { USD: 0 } }`
|
||||
- **THEN** result contains `BTC` with `1 / 67000` and does not contain `FAKE`
|
||||
|
||||
#### Scenario: raises HTTP_ERROR on non-2xx
|
||||
|
||||
- **WHEN** the injected fetch returns 500
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
**Verified by:** `src/providers/cryptocompare/cryptocompare.test.ts::"inverts pricemulti payload and skips zero prices"` AND `src/providers/cryptocompare/cryptocompare.test.ts::"raises HTTP_ERROR on non-2xx response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: KrakenProvider — Ticker with pair→symbol reverse mapping
|
||||
|
||||
The package SHALL export `KrakenProvider implements CurrencyProvider` with
|
||||
`name === "kraken"`, `supportsCrypto === true`, `supportsFiat === false`,
|
||||
defaulting to USD base. It SHALL call `<baseUrl>/Ticker?pair=<pair-list>`,
|
||||
maintain a built-in Kraken-pair-name → canonical-symbol map, invert the
|
||||
`c[0]` price (`1/price`), and skip zero-price entries. A non-empty
|
||||
`result.error[]` SHALL raise `CURRENCY_PROVIDER.UPSTREAM_ERROR` carrying
|
||||
the joined error message as `upstreamMessage`.
|
||||
|
||||
#### Scenario: reverse-maps Kraken pairs and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against a payload
|
||||
containing `XXBTZUSD`, `XETHZUSD`, `SOLUSD`
|
||||
- **THEN** result contains `BTC`, `ETH`, `SOL` with `1 / c[0]` each
|
||||
|
||||
#### Scenario: upstream error array raises UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the upstream returns `{ error: ["EGeneral:Too many requests"], result: {} }`
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
and `upstreamMessage` contains `"Too many requests"`
|
||||
|
||||
**Verified by:** `src/providers/kraken/kraken.test.ts::"reverse-maps Kraken pairs and inverts prices"` AND `src/providers/kraken/kraken.test.ts::"raises UPSTREAM_ERROR on non-empty error array"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: VirtualCurrencyProvider — operator-defined currencies
|
||||
|
||||
The package SHALL export `VirtualCurrencyProvider implements CurrencyProvider`
|
||||
with `name === "virtual-currency"`, `supportsCrypto === false`,
|
||||
`supportsFiat === false`, `supportsVirtual === true`. Construction SHALL
|
||||
register every configured currency with `@suaveplan/money`'s
|
||||
`registerCurrency`. The provider SHALL expose `addCurrency(code, config)`,
|
||||
`updateRate(code, rate)`, `removeCurrency(code)`, `getRate(code)`,
|
||||
`getCurrencyCodes()`. `updateRate` for an unknown code SHALL raise
|
||||
`CURRENCY_PROVIDER.UNKNOWN_CURRENCY`. `fetchRates({ base })` SHALL return
|
||||
all configured rates when `base` matches the configured base, and SHALL
|
||||
convert via `1/rate` (rate-against-base) when `base` is itself a virtual
|
||||
currency. A `base` that is neither the configured base nor a registered
|
||||
virtual code SHALL raise `CURRENCY_PROVIDER.UNSUPPORTED_BASE`.
|
||||
|
||||
#### Scenario: registers currencies and exposes rates
|
||||
|
||||
- **WHEN** the provider is constructed with `{ currencies: { GOLD: {...}, GEMS: {...} } }`
|
||||
- **THEN** `getCurrencyCodes()` returns `["GOLD", "GEMS"]`, `getRate("GOLD")`
|
||||
returns the configured rate, and `fetchRates()` returns both rates against
|
||||
the configured base
|
||||
|
||||
#### Scenario: addCurrency and updateRate mutate at runtime
|
||||
|
||||
- **WHEN** `addCurrency("SILVER", {...})` then `updateRate("SILVER", 99)` are
|
||||
called
|
||||
- **THEN** `fetchRates()` includes `SILVER === 99`
|
||||
|
||||
#### Scenario: updateRate for unknown code raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `updateRate("NOPE", 1)` is called on a fresh provider
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
|
||||
|
||||
#### Scenario: virtual-base conversion
|
||||
|
||||
- **WHEN** `fetchRates({ base: "GOLD" })` is called against
|
||||
`{ GOLD: {rate: 100}, GEMS: {rate: 500} }` with configured base `EUR`
|
||||
- **THEN** `result.base === "GOLD"`, `result.rates.EUR === 1/100`,
|
||||
`result.rates.GEMS === 500/100`
|
||||
|
||||
#### Scenario: unsupported base raises UNSUPPORTED_BASE
|
||||
|
||||
- **WHEN** `fetchRates({ base: "UNKNOWN" })` is called against a provider with
|
||||
no `UNKNOWN` currency
|
||||
- **THEN** it rejects with `code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`
|
||||
|
||||
**Verified by:** `src/providers/virtual/virtual.test.ts::"registers currencies and exposes rates"` AND `src/providers/virtual/virtual.test.ts::"addCurrency and updateRate mutate at runtime"` AND `src/providers/virtual/virtual.test.ts::"updateRate for unknown code raises UNKNOWN_CURRENCY"` AND `src/providers/virtual/virtual.test.ts::"converts when base is a virtual currency"` AND `src/providers/virtual/virtual.test.ts::"raises UNSUPPORTED_BASE for unknown base"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configuration types are named interfaces
|
||||
|
||||
The package SHALL export `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`
|
||||
as named TypeScript interfaces (no inline object literals in provider
|
||||
constructor signatures). Every provider that requires an API key SHALL
|
||||
type its config as `ApiKeyConfig`; every provider without one SHALL type
|
||||
its config as `NoKeyConfig`. Every provider config SHALL extend with an
|
||||
optional `fetch?: FetchLike`.
|
||||
|
||||
#### Scenario: configs are usable from external code
|
||||
|
||||
- **WHEN** a consumer imports `ApiKeyConfig` and passes it to
|
||||
`new FixerProvider(config)`
|
||||
- **THEN** TypeScript accepts the call with `apiKey: string` and rejects a
|
||||
call missing `apiKey`
|
||||
|
||||
**Verified by:** `src/providers/fixer/fixer.test.ts::"accepts ApiKeyConfig with apiKey"` AND `src/providers/ecb/ecb.test.ts::"accepts NoKeyConfig with optional baseUrl"`
|
||||
|
||||
---
|
||||
|
||||
### 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 (a) within `*.select.ts`, `*.native/index.ts`,
|
||||
`*.noble/index.ts` impl-variant forwarders (none exist here) or (b)
|
||||
namespace re-exports (`export * as <ns> from`). Every feature module
|
||||
(`providers/<name>/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. Required subpaths: `./error-codes/error-codes`,
|
||||
`./types/configs`, and one per provider
|
||||
(`./providers/{ecb,fixer,open-exchange-rates,binance,coingecko,coinmarketcap,cryptocompare,kraken,virtual}/<name>`).
|
||||
|
||||
#### Scenario: every feature module has a subpath export
|
||||
|
||||
- **WHEN** `package.json` `exports` map is enumerated
|
||||
- **THEN** it contains at least 11 entries (root `.` + the 10 listed
|
||||
subpaths), each pointing to the matching compiled `.js` and `.d.ts`
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"package.json declares subpath exports for every feature module"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user