first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
# `error-codes` — Typed error registry for currency providers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `CURRENCY_PROVIDER_CODES` registry and the
`CurrencyProviderError` class every provider in
`@suaveplan/currency-providers` raises. The registry guarantees that
downstream code — most importantly `CurrencyConverter`'s fallback chain —
can programmatically dispatch on the namespaced code string instead of
parsing free-form error messages. This is the suaveplan-quality replacement
for the upstream package's plain `throw new Error(...)` pattern, which
gave fallback logic no way to tell "Fixer returned 401 — try the next
provider" apart from "Fixer URL was malformed — give up".
`CurrencyProviderError` extends `SuaveplanError<CurrencyProviderErrorCode>`,
preserving the full `SuaveplanError` contract — `cause` chaining,
`toWire()` redaction, OTel span enrichment, and i18n-key dispatch — while
adding three optional discriminants relevant to provider failures:
`providerName` (which provider hit the error), `status` (the HTTP status
when the failure came from an HTTP response), and `upstreamMessage` (the
free-form error string the upstream API supplied).
## Features
- Eight stable error codes covering every provider failure mode plus the
slim-core registry dispatch: HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR,
UNSUPPORTED_BASE, MISSING_CONFIG, UNKNOWN_CURRENCY,
PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED
- `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>` —
full `SuaveplanError` contract preserved
- Optional `providerName` / `status` / `upstreamMessage` discriminants
- Registered via `defineCodes` so the code values are typed as the
namespaced strings (`"CURRENCY_PROVIDER.HTTP_ERROR"` etc.)
- The first six codes are reachable from sibling
`@suaveplan/currency-provider-<name>` packages (where the concrete HTTP
call sites live); the two `PROVIDER_*` codes are reachable from this
package's `CurrencyProviderRegistry`
## Usage: Basic Example
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
"ECB API error: 503 Service Unavailable",
{ providerName: "ecb", status: 503 }
);
```
## Usage: Advanced Example
Use the codes to drive fallback-chain decisions in a custom converter:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
async function fetchWithFallback(providers: readonly CurrencyProvider[]) {
let lastError: unknown;
for (const provider of providers) {
try {
return await provider.fetchRates();
} catch (error) {
lastError = error;
if (!(error instanceof CurrencyProviderError)) throw error;
// 4xx auth / config errors are not worth retrying with the next
// provider — re-throw to bubble up
if (error.code === CURRENCY_PROVIDER_CODES.MISSING_CONFIG) throw error;
if (error.status === 401 || error.status === 403) throw error;
// anything else: try next provider
}
}
throw lastError;
}
```
## API Reference
- `const CURRENCY_PROVIDER_CODES` — frozen registry mapping each code name
to its namespaced string (`"CURRENCY_PROVIDER.HTTP_ERROR"`, etc.).
- `type CurrencyProviderErrorCode` — union of the eight namespaced strings.
- `interface CurrencyProviderErrorOptions extends SuaveplanErrorOptions` —
adds optional `providerName: string`, `status: number`,
`upstreamMessage: string`.
- `class CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
— constructor `(code, message, options?)`. Read-only `providerName`,
`status`, `upstreamMessage` properties.
## Implementation Notes
- The discriminants are surfaced as top-level read-only properties (rather
than fields on `context`) to make destructured `try/catch` handling
ergonomic — `if (error instanceof CurrencyProviderError && error.status === 429)`
reads naturally.
- Codes that don't have a natural HTTP shape (e.g. `MISSING_CONFIG`,
`UNKNOWN_CURRENCY`) simply omit `status` and `upstreamMessage`. The
optional fields are populated only when meaningful.
- `defineCodes` enforces namespace prefix at runtime — `CURRENCY_PROVIDER.X`
cannot collide with `CURRENCY.X` from `@suaveplan/currency` or any other
package's registry.

View File

@@ -0,0 +1,132 @@
# `factory` — defineCurrencyProvider factory helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling
`@suaveplan/currency-provider-<name>` packages use to declare a provider as
a plain object literal instead of a hand-rolled class. The factory accepts a
`DefineCurrencyProviderInput` (the static identity flags plus a `fetchRates`
function) and returns an `Object.freeze`-d `CurrencyProvider` that
`CurrencyProviderRegistry` and `CurrencyConverter` accept identically to a
class-based provider.
Two idioms are equally supported across the ecosystem — `class
EcbProvider implements CurrencyProvider` for sibling packages that prefer
the OO shape, and `defineCurrencyProvider({ ... })` for packages that prefer
the functional shape. Neither is privileged; the factory exists so sibling
authors do not need to write the `name` / `supportsCrypto` / `supportsFiat`
boilerplate by hand when a closure over a config object is the natural
expression.
## Features
- Returns an `Object.frozen` `CurrencyProvider` — identity stable, no
accidental mutation
- Forwards every `RateFetchOptions` field unchanged to the supplied
`fetchRates` function
- Preserves the optional `supportsVirtual` flag only when explicitly
provided (matches the `CurrencyProvider` contract from
`@suaveplan/currency`)
- Named `DefineCurrencyProviderInput` interface (no inline object literals
in the factory signature) — satisfies AGENTS.md §11
## Usage: Basic Example
```ts
import { defineCurrencyProvider } from "@suaveplan/currency-providers";
const provider = defineCurrencyProvider({
name: "static-eur",
supportsCrypto: false,
supportsFiat: true,
async fetchRates() {
return {
base: "EUR",
rates: { USD: 1.08, GBP: 0.86 },
timestamp: Date.now(),
};
},
});
console.log(provider.name); // "static-eur"
```
## Usage: Advanced Example
Compose the factory with the shared `httpRequest` helper to author a real
provider with three lines of HTTP wiring:
```ts
import {
defineCurrencyProvider,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
const DEFAULT_URL = "https://api.example.com/v1/rates";
export function createExampleProvider(config?: NoKeyConfig) {
const baseUrl = config?.baseUrl ?? DEFAULT_URL;
const fetchImpl = config?.fetch ?? globalThis.fetch;
return defineCurrencyProvider({
name: "example",
supportsCrypto: false,
supportsFiat: true,
async fetchRates(options) {
const response = await httpRequest({
url: baseUrl,
fetch: fetchImpl,
providerName: "example",
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
},
});
}
```
## API Reference
- `interface DefineCurrencyProviderInput` — input contract.
- `name: string` — stable identifier surfaced as `provider.name`.
- `supportsCrypto: boolean`
- `supportsFiat: boolean`
- `supportsVirtual?: boolean` — optional. When omitted, the returned
provider does not declare the property at all (matches the
`CurrencyProvider` contract).
- `fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>`
— the user-supplied implementation. Called directly; the factory
inserts no wrapping behaviour.
- `function defineCurrencyProvider(input: DefineCurrencyProviderInput): CurrencyProvider`
— returns an `Object.freeze`-d `CurrencyProvider`. Identity-stable; safe
to compare with `===`.
## Implementation Notes
- The factory deliberately does **not** apply HTTP error mapping, abort
composition, or schema validation — those concerns live in the
`httpRequest` helper and in each sibling provider's own implementation.
`defineCurrencyProvider` is a thin shape adapter, not a framework.
- `Object.freeze` is shallow — the `name` / `supportsCrypto` /
`supportsFiat` / `supportsVirtual` properties are immutable, but the
closure variables that `fetchRates` captures are owned by the caller and
remain mutable if the caller chose to make them so.
- The `supportsVirtual` field is conditionally included so that providers
that do not deal with virtual currencies surface `undefined` via
`provider.supportsVirtual === undefined` (the contract's "optional"
semantics), rather than always being literally present as `false`.

View File

@@ -0,0 +1,155 @@
# `http` — Shared HTTP helper for sibling provider packages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the two HTTP utilities every sibling
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
async request through the injected `FetchLike` with typed-error mapping and
abort-signal composition) and `composeAbortSignal` (a pure helper that
merges a caller `AbortSignal` with an optional timeout-derived one).
The previous shape of `@suaveplan/currency-providers` had every provider
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
is exactly the signal that the helper belongs in the slim core: every
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
and the HTTP_ERROR call site lives in one place, exercised by one test
file.
## Features
- `httpRequest` — single async helper covering the full HTTP lifecycle:
abort-signal composition, timeout derivation, fetch invocation, non-2xx
→ `CurrencyProviderError(HTTP_ERROR)` mapping
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
only, timeout only, both (via `AbortSignal.any`)
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
`.arrayBuffer()` according to their own payload shape — no premature
body-format assumption
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
- Typed error carries `providerName` and `status` discriminants so audit
log handlers can dispatch without parsing the message string
## Usage: Basic Example
```ts
import { httpRequest } from "@suaveplan/currency-providers";
const response = await httpRequest({
url: "https://api.example.com/v1/rates",
fetch: globalThis.fetch,
providerName: "example",
});
const payload = await response.json();
```
## Usage: Advanced Example
Wire `httpRequest` into a sibling provider with both a timeout and the
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
typed code rather than the message:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
const DEFAULT_URL = "https://api.example.com/v1/rates";
const NAME = "example";
export class ExampleProvider {
public readonly name = NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl;
constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
try {
const response = await httpRequest({
url: this.baseUrl,
fetch: this.fetchImpl,
providerName: NAME,
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
) {
// optionally: backoff, fallback, audit-log etc.
}
throw error;
}
}
}
```
## API Reference
- `interface HttpRequestInput`
- `url: string` — fully-resolved request URL (query string already
appended at the call site).
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
defaults to `globalThis.fetch` at construction time).
- `providerName: string` — carried on the raised
`CurrencyProviderError.providerName`.
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
body, etc.). The helper preserves caller-supplied `headers` and
`method`; it only inserts `signal` when an abort signal needs to be
attached.
- `signal?: AbortSignal` — caller's abort signal (usually from
`RateFetchOptions.signal`).
- `timeoutMs?: number` — when set, the helper derives
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
resolves to the raw `Response` on 2xx, rejects with
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
— pure four-way composition. Returns the single input verbatim when only
one is provided (no wrapping cost). Returns
`AbortSignal.any([caller, timeout])` when both are present.
## Implementation Notes
- The helper does **not** apply retry, backoff, or rate-limit semantics —
those wrappers compose at a higher layer (see the deferred surfaces in
the 2026-05-17 archive's `## Out of scope`).
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
AbortController` to keep the helper universal (no `setTimeout`
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
pending timer outliving a fast-success request.
- `composeAbortSignal` deliberately returns the **single input** unchanged
when only one is provided. Wrapping a single signal through
`AbortSignal.any([s])` would still produce a valid signal but would
break identity-comparison invariants downstream consumers might rely on.
- The non-2xx error message intentionally uses `response.statusText` (the
HTTP reason phrase) so the human-readable form survives audit log
redaction; programmatic dispatch should use `error.code` and
`error.status` instead.

View File

@@ -0,0 +1,128 @@
# `registry` — Name-keyed lookup of CurrencyProvider instances
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `CurrencyProviderRegistry`, the in-memory registry that sibling
`@suaveplan/currency-provider-<name>` packages register their providers
with and that consumers (the `CurrencyConverter` wiring, future
`AggregatorProvider` / `RateLimitedProvider` wrappers, admin UIs that toggle
providers on and off) use to resolve providers by name without tracking
concrete classes themselves.
The registry is **opt-in convenience**, not a required indirection:
`new CurrencyConverter({ provider: new EcbProvider() })` continues to work
without a registry. The registry exists for callers that wire many providers
from configuration, that need a single canonical instance of each provider,
or that want a uniform "look up by name" surface to feed into wrappers.
## Features
- Five-method API — `register`, `get`, `has`, `list`, `unregister`
- Typed `CurrencyProviderError` on every failure mode (no plain `Error`s)
- `register` throws `PROVIDER_ALREADY_REGISTERED` on duplicate names —
explicit `unregister` + `register` required to replace
- `get` throws `PROVIDER_NOT_REGISTERED` on unknown names; `has` is a
non-throwing probe for the same condition
- `list()` returns a **sorted snapshot** so equal sets of providers
produce identical listings regardless of insertion order — keeps
downstream golden-file tests deterministic
- `unregister` is idempotent — returns `true` when something was removed,
`false` otherwise, never throws
- Providers stored by reference (no cloning / freezing / defensive copy)
## Usage: Basic Example
```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")],
});
```
## Usage: Advanced Example
Wire providers from a config file, swap them at runtime, and enumerate them
deterministically for a status dashboard:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import type { CurrencyProvider } from "@suaveplan/currency";
interface ProviderModule {
readonly create: () => CurrencyProvider;
}
function wire(
modules: Readonly<Record<string, ProviderModule>>,
enabled: readonly string[]
): CurrencyProviderRegistry {
const registry = new CurrencyProviderRegistry();
for (const key of enabled) {
const mod = modules[key];
if (mod !== undefined) registry.register(mod.create());
}
return registry;
}
// Swap "ecb" for a different ECB instance with a custom fetch
const registry = wire(modules, ["ecb", "coingecko", "kraken"]);
registry.unregister("ecb");
registry.register(new EcbProvider({ fetch: instrumentedFetch }));
// Dashboard: always shows providers alphabetically
for (const name of registry.list()) {
console.log(`provider: ${name}`);
}
```
## API Reference
- `class CurrencyProviderRegistry` — exported class. Holds a `Map<string,
CurrencyProvider>` internally.
- `register(provider: CurrencyProvider): void` — store the provider under
`provider.name`. Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and
`providerName === provider.name` when the name is already in use.
- `get(name: string): CurrencyProvider` — return the registered provider.
Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and
`providerName === name` when nothing is registered under `name`.
- `has(name: string): boolean` — non-throwing probe.
- `list(): readonly string[]` — sorted snapshot of currently registered
names.
- `unregister(name: string): boolean` — `true` when something was
removed, `false` otherwise. Never throws.
## Implementation Notes
- The registry is **single-tenant**: there is no global / singleton
instance. Callers construct their own registry. Multiple registries can
coexist (e.g. one per tenant in a multi-tenant SaaS), and a provider
instance may be registered with more than one registry simultaneously.
- `register` is idempotent only at the identity level: registering the same
instance under the same name twice still 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, so the contract here stays
unambiguous.
- `list()` returns a fresh `string[]` on every call (the sort happens
in-place on a copy). Callers may treat the result as immutable, but the
registry does not freeze it — a `readonly string[]` type hint conveys the
intent at the TypeScript boundary.
- Both `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` carry
`providerName` on the thrown `CurrencyProviderError`, so callers writing
cross-cutting error handlers (audit logs, telemetry) can structure on the
discriminant rather than parsing the message string.

View File

@@ -0,0 +1,98 @@
# `configs` — Provider configuration types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the three configuration interfaces every concrete provider in
`@suaveplan/currency-providers` consumes: `ApiKeyConfig` for providers that
require an API key (Fixer, Open Exchange Rates, CoinMarketCap),
`NoKeyConfig` for providers that talk to a public endpoint (ECB, Binance,
CoinGecko, CryptoCompare, Kraken), and `HttpFetchOptions` for the per-call
HTTP knobs callers can set on top. All three are named interfaces — never
inline object literals in provider constructor signatures — so consumers
can import the type, spread it into their own config bag, and let
TypeScript narrow on the discriminating fields.
Every config inherits from `ProviderConfigBase`, which adds the two
universal knobs every provider honours: an optional `baseUrl` (for proxy /
staging / fixture endpoints) and an optional `fetch: FetchLike` (the
injectable WHATWG-shape closure that lets tests pass a controlled stub
without `vi.stubGlobal('fetch', ...)`). The `FetchLike` type comes from
`@suaveplan/types` — `globalThis.fetch` is directly assignable to it.
## Features
- `ApiKeyConfig` — `apiKey: string` (required) + `baseUrl?` + `fetch?`
- `NoKeyConfig` — `baseUrl?` + `fetch?` (no API key field)
- `HttpFetchOptions` — `timeoutMs?` + `headers?` per-call knobs (reserved for
the retry / timeout wrapper landing under `## Out of scope`)
- All interfaces use `readonly` fields so config bags are immutable
## Usage: Basic Example
```ts
import { FixerProvider } from "@suaveplan/currency-providers";
import type { ApiKeyConfig } from "@suaveplan/currency-providers";
const config: ApiKeyConfig = { apiKey: process.env.FIXER_KEY! };
const fixer = new FixerProvider(config);
```
## Usage: Advanced Example
Inject a custom `fetch` for hermetic tests:
```ts
import { EcbProvider } from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const fakeFetch: FetchLike = async (input) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/eurofxref-daily.xml")) {
return new Response(
`<?xml version="1.0"?><Cube><Cube currency='USD' rate='1.087'/></Cube>`,
{ status: 200 }
);
}
return new Response("not found", { status: 404 });
};
const ecb = new EcbProvider({
baseUrl: "https://staging.example.com/eurofxref-daily.xml",
fetch: fakeFetch,
});
const result = await ecb.fetchRates({ base: "EUR" });
```
## API Reference
- `interface ProviderConfigBase` — `baseUrl?: string`, `fetch?: FetchLike`.
Internal base shape every concrete config extends.
- `interface ApiKeyConfig extends ProviderConfigBase` — adds
`apiKey: string`. Providers that consume it raise
`CURRENCY_PROVIDER.MISSING_CONFIG` if `apiKey` is empty.
- `interface NoKeyConfig extends ProviderConfigBase` — empty extension; type
alias name signals "no key required". Allows future per-no-key fields
without touching the base.
- `interface HttpFetchOptions` — `timeoutMs?: number`,
`headers?: Readonly<Record<string, string>>`. Reserved for the retry /
timeout wrapper.
## Implementation Notes
- The base interface intentionally accepts `FetchLike` (structural,
three-argument or two-argument doesn't matter) instead of the global
`typeof fetch`. This lets callers pass tighter stubs — e.g. a closure
that only handles a single URL pattern.
- `HttpFetchOptions` exists in the public surface today even though no
provider consumes it yet, because it is exported by the upstream
`@suaveplan/currency-providers` package and consumer code that imported
the type would break if it disappeared. Once `@suaveplan/retry` is wired
in, every provider's `fetchRates` will accept it as a second argument.
- Empty-extension `interface NoKeyConfig extends ProviderConfigBase {}` is
intentional and not a TypeScript anti-pattern: it gives the no-key
providers a distinct, importable type name so consumers can spread it
into their own configs without coupling to the base name.