Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# @suaveplan/currency - Universal currency conversion engine — pluggable CurrencyProvider interface, TTL-bounded RateCache with direct / inverse / triangulated lookup, primary + fallback chain with AbortSignal cancellation, Zod-validated public boundary, typed CurrencyError hierarchy with stable CURRENCY.* code registry, virtual-currency seeding with post-construction setVirtualRate / updateVirtualRate mutators, enriched ConversionResult carrying providerName and triangulatedThrough diagnostics. Built on @suaveplan/money minor-unit bigint arithmetic.
Universal currency conversion engine — pluggable `CurrencyProvider`
interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup,
primary + fallback chain with `AbortSignal` cancellation, Zod-validated public
boundary, typed `CurrencyError` hierarchy with stable `CURRENCY.*` code
registry, virtual-currency seeding with post-construction
`setVirtualRate` / `updateVirtualRate` mutators, and enriched `ConversionResult`
carrying `providerName` and `triangulatedThrough` diagnostics. Built on
`@suaveplan/money` minor-unit `bigint` arithmetic.
<!-- begin:auto-docs -->
## Documentation tree
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
- [types](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
- [common](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency @suaveplan/money
```
## Quick start
```ts
import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const usd = Money.fromMajor("100", "USD");
const result = await converter.convert(usd, "EUR");
// result.amount === 9200n
// result.rate === 0.92
// result.providerName === "in-memory"
// result.triangulatedThrough === null
const eur = await converter.convertAmount(250, "USD", "EUR");
// eur === 230 (250 * 0.92)
```
## Features
- **Pluggable `CurrencyProvider` interface** for any exchange-rate source.
- **In-memory `RateCache`** with configurable TTL eviction.
- **Cross-rate triangulation** via cached intermediate base (single-hop).
- **Inverse rate calculation** from cached data when only one direction is
known.
- **Provider fallback chain** for resilience — tries providers in order.
- **`AbortSignal` cancellation** on `convert` / `convertAmount` / `getRate`,
honoured between fallback attempts.
- **Zod-validated boundary** on every public option bundle.
- **Typed `CurrencyError` hierarchy** with stable `CURRENCY.*` codes
(`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
`ABORTED`).
- **Virtual-currency seeding** for game currencies, with
`setVirtualRate` / `updateVirtualRate` post-construction mutation.
- **Enriched `ConversionResult`** — `providerName` (`"<identity>"` / `"<cache>"`
/ `"<virtual>"` / actual provider name) plus `triangulatedThrough` (chain of
intermediate currencies for triangulated rates).
- **Built-in `InMemoryProvider` + `createStaticProvider` factory** for tests
and fixed-rate apps.
- **Tree-shakeable subpath exports** per public module.
## Modules
- [`src/converter/converter.md`](./src/converter/converter.md) —
`CurrencyConverter` orchestrator (provider chain, cache, triangulation,
virtual currency, AbortSignal).
- [`src/rate-cache/rate-cache.md`](./src/rate-cache/rate-cache.md) —
TTL-bounded `RateCache` with direct / inverse / triangulated lookup.
- [`src/provider/provider.md`](./src/provider/provider.md) —
`InMemoryProvider` + `createStaticProvider` reference implementations.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`CURRENCY_CODES` registry + `CurrencyError` discriminated class.
- [`src/schemas/schemas.md`](./src/schemas/schemas.md) — Zod schemas + parse
helpers for every public option bundle.
- [`src/types/common.md`](./src/types/common.md) — public type surface
(`CurrencyProvider`, `ConversionResult`, `ConverterConfig`, …).
## Status
`0.1.0` (pre-graduation). Migrated from
`../suaveplan/packages/finance/currency/` with feature parity plus typed
errors, Zod boundary, `AbortSignal` support, and enriched
`ConversionResult`. See
the design proposal for the migration
record and the deferred-feature list.

View File

@@ -0,0 +1,185 @@
# `converter` — `CurrencyConverter` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It
combines a pluggable `CurrencyProvider` (plus an ordered fallback chain), a
TTL-bounded `RateCache`, virtual-currency seeding, cross-rate triangulation,
and `AbortSignal` cancellation into a single typed API that converts a
`MoneyLike` value (or plain numeric amount) between any two currencies. Every
public boundary is Zod-validated; every failure path raises a typed
`CurrencyError`; every successful conversion is enriched with `providerName`
and `triangulatedThrough` diagnostics so callers can observe which source
answered and whether the rate is direct or derived.
The converter does not perform I/O itself — it delegates to the provider chain
— which keeps it universal. Reference providers ship in `./provider`;
external providers (ECB, Fixer, CoinGecko, Binance, …) live in
`@suaveplan/currency-providers`. The class is intentionally generic-free; the
markup-policy / multi-tenant work flagged in the enterprise audit will land
in a follow-up change.
## Features
- Primary + ordered fallback `CurrencyProvider` chain (`config.fallbackProviders`).
- TTL-bounded cache (`config.staleDurationMs`, default 1 hour).
- Cross-rate triangulation via cached intermediate base (single-hop;
multi-hop BFS deferred).
- Identity short-circuit when `from === to`.
- Virtual-currency seeding (`config.virtualCurrencies`) with post-construction
`setVirtualRate` / `updateVirtualRate` mutators (aliases) and
`getVirtualRates` inspector. Re-seeded after every provider refresh so
virtual rates survive.
- `AbortSignal` honoured on every public method and between fallback attempts.
- Zod-validated constructor and call sites — failures raise
`CurrencyError({ code: CURRENCY.INVALID_CONFIG | CURRENCY.INVALID_OPTIONS })`.
- Enriched `ConversionResult` — `providerName` (`"<identity>"` / `"<cache>"` /
`"<virtual>"` / actual provider name) and `triangulatedThrough` (the
intermediate base for triangulated rates, otherwise `null`).
- Management surface: `setRate`, `clearCache`, `getCache`.
## Usage: Basic Example
```ts
import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const result = await converter.convert(
Money.fromMajor("100", "USD"),
"EUR"
);
// result.amount === 9200n, result.rate === 0.92,
// result.providerName === "in-memory", result.triangulatedThrough === null
```
## Usage: Advanced Example
A multi-source converter with a fallback provider, virtual currency, and
`AbortSignal` cancellation:
```ts
import {
CurrencyConverter,
InMemoryProvider,
createStaticProvider,
CurrencyError,
CURRENCY_CODES,
} from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const primary = new InMemoryProvider();
primary.setRates("USD", { EUR: 0.5, JPY: 100 });
const fallback = createStaticProvider("static", {
USD: { EUR: 0.51, JPY: 99 },
});
const converter = new CurrencyConverter({
provider: primary,
fallbackProviders: [fallback],
defaultBase: "USD",
virtualCurrencies: {
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
},
});
// Triangulated conversion via USD.
const eurToJpy = await converter.convert(Money.fromMajor("10", "EUR"), "JPY");
// eurToJpy.triangulatedThrough === ["USD"], eurToJpy.rate === 200
// Virtual-currency conversion.
const gold = await converter.convertAmount(1, "USD", "GOLD");
// gold === 100; converter.getVirtualRates() === { GOLD: 100 }
// Cancellation.
const ac = new AbortController();
queueMicrotask(() => ac.abort());
try {
await converter.convert(Money.fromMajor("1", "USD"), "EUR", {
signal: ac.signal,
fresh: true,
});
} catch (err) {
if (
err instanceof CurrencyError &&
err.code === CURRENCY_CODES.ABORTED
) {
console.warn("Aborted as expected");
} else {
throw err;
}
}
```
## API Reference
### `new CurrencyConverter(config: ConverterConfig)`
Validates `config` through `ConverterConfigSchema`; raises
`CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure.
### `convert(money: MoneyLike, to: CurrencyCode, options?: ConvertOptions): Promise<ConversionResult>`
Converts `money` to `to`. Returns an enriched `ConversionResult`. Raises:
- `CURRENCY.INVALID_OPTIONS` — bad `options`.
- `CURRENCY.ABORTED` — `options.signal` aborted at call time or between
fallback attempts.
- `CURRENCY.PROVIDER_UNAVAILABLE` — every provider in the chain failed.
- `CURRENCY.RATE_UNAVAILABLE` — no rate path exists even after refresh.
### `convertAmount(amount: number, from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Numeric counterpart of `convert`; same errors apply.
### `getRate(from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Returns the multiplicative rate for `from → to`. Same errors apply.
### `setVirtualRate(code: string, rate: number): void` / `updateVirtualRate(code: string, rate: number): void`
Update a virtual currency's rate against `defaultBase` at runtime. Both
names are aliases; the audit named `setVirtualRate`, and the upstream library
named `updateVirtualRate`.
### `getVirtualRates(): Readonly<Record<string, number>>`
Defensive copy of the in-memory virtual rate map.
### `setRate(from: CurrencyCode, to: CurrencyCode, rate: number): void` / `clearCache(): void` / `getCache(): RateCache`
Direct cache management — useful for testing and overrides.
## Implementation Notes
The converter stores a per-pair `rateOrigin` map so it can report
`ConversionResult.providerName` correctly across the four sources
(`<identity>` / `<cache>` / `<virtual>` / actual provider). The
`justFetchedFromProvider` field is set transiently inside `resolveRate` so the
immediate read after a successful fetch surfaces the real provider name;
subsequent calls hit the cache and report `"<cache>"`.
`seedVirtualRates` runs after every successful provider refresh so virtual
rates survive new fetches. It writes virtual entries with origin
`"<virtual>"`; the lookup code therefore reports `"<virtual>"` for any
conversion that targets a virtual currency, even after later fetches refresh
other rates in the same cache entry.
The single-hop triangulation policy preserves upstream behaviour bit-for-bit;
multi-hop BFS is deferred (see proposal `## Out of scope`). The
`triangulatedThrough` field is typed as `readonly string[] | null` to leave
the door open for future multi-hop work without a breaking type change.
`Math.round` on the converted minor amount preserves the upstream rounding
behaviour. The per-pair rounding-mode policy from the enterprise audit is
deferred to a dedicated proposal because it is a behaviour change that
affects every caller.

View File

@@ -0,0 +1,105 @@
# `error-codes` — `CURRENCY_CODES` + `CurrencyError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/currency` raises only typed errors. This module owns the stable
`CURRENCY_CODES` registry (built with `defineCodes("CURRENCY", [...])` from
`@suaveplan/error`) and the `CurrencyError` subclass that every public failure
path constructs. Consumers programmatically dispatch on the namespaced strings
(`"CURRENCY.PROVIDER_UNAVAILABLE"`, `"CURRENCY.RATE_UNAVAILABLE"`, ...) and
inspect the `from` / `to` / `providerName` discriminants to recover from or
report on failures.
The class extends `SuaveplanError<CurrencyErrorCode>`, so
`instanceof CurrencyError` narrows `err.code` to the literal union, and
`instanceof SuaveplanError` carries the full project contract: `.code`,
`.cause`, `.context`, `.timestamp`, `.traceId`. Seven codes are declared:
`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
`ABORTED`. Every code is reachable from a real production call site —
registry pollution is banned by repo policy.
## Features
- Stable namespaced codes via `defineCodes("CURRENCY", [...] as const)` — the
registry is frozen, the namespace is encoded into each value.
- `CurrencyError extends SuaveplanError<CurrencyErrorCode>` — `instanceof`
narrows `.code` to the literal-string union; `cause` chains arbitrary
throwables.
- `from` / `to` / `providerName` discriminants on every error instance for
programmatic diagnostics (`err.from === "USD"`, `err.providerName === "ecb"`).
- Co-located test pins every code to a real call site so the registry never
drifts ahead of the implementation.
## Usage: Basic Example
```ts
import { CurrencyConverter, CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
try {
await converter.convert(money, "XYZ");
} catch (err) {
if (err instanceof CurrencyError && err.code === CURRENCY_CODES.RATE_UNAVAILABLE) {
console.error(`No rate for ${err.from} → ${err.to}`);
} else {
throw err;
}
}
```
## Usage: Advanced Example
Wrap an upstream provider failure while keeping the original throwable on
`cause` so downstream loggers can still report it, and annotate the typed
error with the provider that triggered the fallback chain failure:
```ts
import { CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
async function safeFetch(provider) {
try {
return await provider.fetchRates({ base: "USD" });
} catch (err) {
throw new CurrencyError(
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
`provider ${provider.name} failed`,
{ cause: err, providerName: provider.name, from: "USD" }
);
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `CURRENCY_CODES` | `const` (frozen) | Registry of seven namespaced codes built via `defineCodes`. |
| `CurrencyErrorCode` | `type` | Literal-string union of every registry value. |
| `CurrencyErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with optional `from`, `to`, `providerName`. |
| `CurrencyError` | `class` | `extends SuaveplanError<CurrencyErrorCode>` — constructor takes `(code, message, options?)`. |
### `CurrencyError` properties
- `code: CurrencyErrorCode` — the namespaced error code (e.g. `"CURRENCY.PROVIDER_UNAVAILABLE"`).
- `from?: CurrencyCode` — the source currency the operation was attempted against.
- `to?: CurrencyCode` — the target currency the operation was attempted against.
- `providerName?: string` — the provider whose call produced the error.
- `cause?: unknown` — chained underlying throwable.
- `timestamp: number` — captured at construction time via the injected clock.
- `context?: ErrorContext` — bag of structured fields propagated to the formatter.
- `traceId?: string` — propagated W3C trace identifier when supplied.
## Implementation Notes
The constructor is `public` so any module in the package can throw it; the
optional discriminants are assigned only when set so `JSON.stringify` does not
emit `"from": undefined`. `CurrencyError` is `instanceof SuaveplanError` and
`instanceof Error`; tests pin both. The registry is created with `as const`
so TypeScript infers literal types — `CURRENCY_CODES.PROVIDER_UNAVAILABLE` is
`"CURRENCY.PROVIDER_UNAVAILABLE"`, not `string`. Adding a new code requires
appending to the array in `error-codes.ts`, wiring it into a real production
raise site, and updating the registry test to assert the literal value.

View File

@@ -0,0 +1,108 @@
# `provider` — reference `CurrencyProvider` implementations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The provider module ships two reference implementations of the
`CurrencyProvider` contract: a mutable `InMemoryProvider` for tests and
fixed-rate scenarios, and a `createStaticProvider(name, rateMap)` factory
that returns a frozen provider built from a static rate map. Both share the
same internal helpers for `RateFetchOptions.targets` filtering and
`AbortSignal` handling. External providers (ECB, Fixer, CoinGecko, Binance,
…) live in a separate `@suaveplan/currency-providers` package that consumes
the same contract.
Both reference providers raise `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })`
when no rates are configured for the requested base, and
`CurrencyError({ code: CURRENCY.ABORTED, providerName })` when called with
an already-aborted `AbortSignal`. The `name` field is surfaced on
`ConversionResult.providerName` so consumers can attribute every successful
conversion to its source.
## Features
- `InMemoryProvider` — `setRates(base, rates)`, `setRate(from, to, rate)`,
`fetchRates(options?)`. `supportsCrypto` / `supportsFiat` /
`supportsVirtual` configurable at construction.
- `createStaticProvider(name, rateMap, options?)` — frozen `CurrencyProvider`
serving a static rate map.
- AbortSignal honoured pre-fetch on both implementations.
- `RateFetchOptions.targets` filtering — only the requested target rates are
returned.
- Missing-base error raises typed `CURRENCY.PROVIDER_UNAVAILABLE` with the
provider name on the discriminant.
## Usage: Basic Example
```ts
import { InMemoryProvider, CurrencyConverter } from "@suaveplan/currency";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const eur = await converter.convertAmount(100, "USD", "EUR"); // 92
```
## Usage: Advanced Example
Compose a static fallback chain with `createStaticProvider`, using filtered
target rates to keep payloads small:
```ts
import {
createStaticProvider,
InMemoryProvider,
CurrencyConverter,
} from "@suaveplan/currency";
const primary = new InMemoryProvider();
primary.setRates("USD", { EUR: 0.92 });
const fallback = createStaticProvider(
"static-fallback",
{
USD: { EUR: 0.91, GBP: 0.78 },
},
{ supportsCrypto: false }
);
const converter = new CurrencyConverter({
provider: primary,
fallbackProviders: [fallback],
});
const ac = new AbortController();
queueMicrotask(() => ac.abort());
try {
await converter.convert(
{ amount: 100n, currency: "USD" },
"EUR",
{ signal: ac.signal }
);
} catch (err) {
// err is CurrencyError({ code: "CURRENCY.ABORTED" })
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `InMemoryProvider` | class | `name = "in-memory"`; `setRates`, `setRate`, `fetchRates`. |
| `createStaticProvider(name, rateMap, options?)` | factory | Returns a frozen `CurrencyProvider` answering from `rateMap`. |
| `ReferenceProviderOptions` | interface | Optional `supportsCrypto` / `supportsFiat` / `supportsVirtual` flags. |
## Implementation Notes
`InMemoryProvider` stores rates in a `Map<string, Record<string, number>>`;
`setRates` copies the input object so later caller mutations don't leak.
`createStaticProvider`'s returned object is `Object.freeze`d so callers can't
mutate `name` / `supportsCrypto` / `supportsFiat` after construction. Abort
handling is intentionally pre-fetch only — the reference providers do no
real I/O, so once `fetchRates` resolves there is no in-flight call to
abort. External providers (HTTP, WebSocket) propagate the signal into their
network call.

View File

@@ -0,0 +1,91 @@
# `rate-cache` — TTL-bounded rate cache with triangulated lookup
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RateCache` is the converter's in-memory rate store. It folds every successful
`fetchRates` answer into a single map keyed by base currency, evicts entries
older than the configured TTL, and resolves rates for any pair through one of
four paths: identity (`from === to`), direct (cache has a `from` base with a
`to` rate), inverse (cache has a `to` base with a `from` rate — return
`1 / rate`), or triangulated (any cached non-`from`/`to` base contains both
`from` and `to`). The cache is universal: only `Date.now`, `Map`, and
plain object access.
The cache is a pure storage substrate — it does not call providers, does not
seed virtual rates, and does not raise typed errors. Those concerns live in
`CurrencyConverter`. Keeping the cache concern-free makes it cheap to swap
for a persistent adapter in a follow-up change (see proposal `## Out of
scope`).
## Features
- TTL-bounded entries (`set` records `Date.now() + ttlMs` as `expiresAt`).
- Lazy eviction — expired entries are deleted on `get` / `has`.
- `getRate(from, to)` resolves identity, direct, inverse, and triangulated
matches.
- `getTriangulationPath(from, to)` surfaces the intermediate base used for
triangulation (or `undefined` for direct / inverse / identity / miss).
- `prune()` removes every expired entry and returns the count removed.
- `clear()`, `size`, `getTimestamp(base)` round out the inspection surface.
- Skips zero rates during inverse / triangulation lookup to avoid `Infinity`.
## Usage: Basic Example
```ts
import { RateCache } from "@suaveplan/currency";
const cache = new RateCache(60_000); // 1-minute TTL
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: Date.now() });
cache.getRate("USD", "EUR"); // 0.92
cache.getRate("EUR", "USD"); // 1 / 0.92 (inverse)
cache.getRate("USD", "USD"); // 1 (identity)
```
## Usage: Advanced Example
Use the triangulation path to log which intermediate base resolved a quote:
```ts
import { RateCache } from "@suaveplan/currency";
const cache = new RateCache();
cache.set("USD", {
base: "USD",
rates: { EUR: 0.5, JPY: 100 },
timestamp: Date.now(),
});
const rate = cache.getRate("EUR", "JPY"); // 200 (100 / 0.5)
const through = cache.getTriangulationPath("EUR", "JPY"); // "USD"
console.log(`EUR → JPY via ${through} = ${rate}`);
```
## API Reference
| Member | Kind | Description |
|---|---|---|
| `new RateCache(ttlMs?)` | constructor | `ttlMs` defaults to `3_600_000` (1 hour). |
| `set(base, result)` | method | Stores a `RateFetchResult` and stamps `expiresAt`. |
| `get(base)` | method | Returns the cached `RateFetchResult` or `undefined`; deletes expired entries. |
| `has(base)` | method | `get(base) !== undefined`. |
| `getRate(from, to)` | method | Number for identity / direct / inverse / triangulated matches, otherwise `undefined`. |
| `getTriangulationPath(from, to)` | method | The intermediate base when triangulation hit, otherwise `undefined`. |
| `getTimestamp(base)` | method | `result.timestamp` of the fresh entry; `undefined` when missing or expired. |
| `prune()` | method | Deletes every expired entry; returns the count removed. |
| `clear()` | method | Empties the cache. |
| `size` | getter | The underlying `Map.size` (includes potentially expired entries until pruned). |
## Implementation Notes
`size` deliberately mirrors the underlying `Map.size` so callers can observe
hold-over expired entries before calling `prune()` — the counterpart `has` is
TTL-aware. Lookup avoids dividing by `0` (a degenerate inverse rate) by
short-circuiting the inverse branch when `rate === 0`; the same guard
protects triangulation against a zero `fromRate`. The triangulation loop
skips bases equal to `from` or `to` because those would be the inverse /
direct path the previous branches already covered.

View File

@@ -0,0 +1,106 @@
# `schemas` — Zod boundary for `@suaveplan/currency`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The schemas module is the package's input-validation boundary. Every public
options bundle that crosses into a `CurrencyConverter` method or a reference
provider is parsed against a Zod schema first; failures raise a typed
`CurrencyError` with code `CURRENCY.INVALID_CONFIG` (constructor arguments,
virtual-currency entries) or `CURRENCY.INVALID_OPTIONS` (per-call options,
`RateFetchOptions`). The Zod issue array is attached to
`error.context.issues` so downstream loggers and APIs can surface field-level
diagnostics without re-running validation.
Centralising validation here keeps the converter and provider implementations
focused on behaviour: by the time control enters `convert` or `fetchRates`,
every option is already the shape the implementation expects, and the
implementation can rely on TypeScript's structural typing without runtime
double-checks.
## Features
- `ConverterConfigSchema` — validates the constructor argument (provider
shape, optional fallback array, positive integer TTL, currency-code
default base, virtual-currency record).
- `ConvertOptionsSchema` — validates per-call options (`rounding` enum,
optional `fresh: boolean`, optional `AbortSignal`).
- `VirtualCurrencyConfigSchema` — `rate > 0`, non-empty `name` / `symbol`,
optional `exponent ∈ [0, 18]` / `issuerId`.
- `RateFetchOptionsSchema` — optional `base`, `targets` array of currency
codes, optional `AbortSignal`.
- `parseConverterConfig` / `parseConvertOptions` /
`parseVirtualCurrencyConfig` / `parseRateFetchOptions` — safe-parse
helpers that raise the appropriate `CurrencyError` on failure.
## Usage: Basic Example
```ts
import { parseConvertOptions } from "@suaveplan/currency";
const options = parseConvertOptions({ rounding: "half-up", fresh: false });
// options is typed as ConvertOptions; bad shapes raise CURRENCY.INVALID_OPTIONS.
```
## Usage: Advanced Example
A consumer wraps API input through the schemas before delegating to the
converter, surfacing Zod issues as user-facing field errors:
```ts
import {
CurrencyError,
CURRENCY_CODES,
parseConverterConfig,
CurrencyConverter,
} from "@suaveplan/currency";
export function makeConverter(raw: unknown) {
try {
const config = parseConverterConfig(raw);
return new CurrencyConverter(config);
} catch (err) {
if (
err instanceof CurrencyError &&
err.code === CURRENCY_CODES.INVALID_CONFIG
) {
const issues = (err.context?.issues as { path: (string | number)[]; message: string }[] | undefined) ?? [];
throw new Error(
`Bad converter config: ${issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ")}`
);
}
throw err;
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `ConverterConfigSchema` | Zod schema | Validates `ConverterConfig`. |
| `ConvertOptionsSchema` | Zod schema | Validates `ConvertOptions`. |
| `VirtualCurrencyConfigSchema` | Zod schema | Validates a single `VirtualCurrencyConfig`. |
| `RateFetchOptionsSchema` | Zod schema | Validates `RateFetchOptions`. |
| `parseConverterConfig(input)` | `(unknown) => ConverterConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
| `parseConvertOptions(input)` | `(unknown) => ConvertOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
| `parseVirtualCurrencyConfig(input)` | `(unknown) => VirtualCurrencyConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
| `parseRateFetchOptions(input)` | `(unknown) => RateFetchOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
## Implementation Notes
The `signal` field uses a `z.custom<AbortSignal>` because `AbortSignal` is a
host object — Zod cannot introspect it. The custom predicate accepts any
object exposing an `aborted` property, which matches both the platform
`AbortSignal` and consumer-side polyfills. `provider` is validated
structurally (`name`, `supportsCrypto`, `supportsFiat`, optional
`supportsVirtual`, function-typed `fetchRates`); we deliberately do not
descend into the function body. Currency codes are validated as non-empty
strings — the converter checks against the `@suaveplan/money` registry at
use time, so unknown codes raise `CURRENCY.UNSUPPORTED_CURRENCY` (via
`getCurrencyOrThrow`) rather than failing schema validation.

View File

@@ -0,0 +1,112 @@
# `types/common` — public type surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single home for every shared type the `@suaveplan/currency` public surface
exposes. The module defines the `CurrencyProvider` contract every rate source
implements, the option bundles for the converter constructor and its three
public methods (`convert`, `convertAmount`, `getRate`), the `RateCache` wire
shape (`RateFetchResult`), and the enriched `ConversionResult` returned by
`convert`. Every interface is named (no inline shapes — AGENTS.md §11) so
downstream consumers can import the same name and structural-type assertions
stay tight.
Types live here rather than inside their feature module because they cross
module boundaries: the converter consumes `CurrencyProvider`, the provider
module satisfies it, the cache stores `RateFetchResult`, and the schemas module
mirrors each bundle into a Zod parser. Centralising them keeps the contract in
one auditable place.
## Features
- `CurrencyProvider` contract: `name`, `supportsCrypto`, `supportsFiat`,
optional `supportsVirtual`, `fetchRates(options?)`.
- `RateFetchOptions` / `RateFetchResult` — the wire shape every provider speaks.
- `ConverterConfig` — constructor arg for `CurrencyConverter`.
- `ConvertOptions` — per-call options bundle (rounding, fresh, signal).
- `ConversionResult` — converter return shape; enriched beyond upstream with
`providerName` and `triangulatedThrough` diagnostics.
- `VirtualCurrencyConfig` — fixed-rate virtual / game currency configuration.
- `ExchangeRate` — single-pair wire shape (reserved for external transports).
## Usage: Basic Example
```ts
import type {
CurrencyProvider,
ConverterConfig,
ConversionResult,
} from "@suaveplan/currency";
const provider: CurrencyProvider = {
name: "ecb",
supportsCrypto: false,
supportsFiat: true,
async fetchRates() {
return { base: "EUR", rates: { USD: 1.08 }, timestamp: Date.now() };
},
};
const config: ConverterConfig = { provider, defaultBase: "EUR" };
```
## Usage: Advanced Example
A consumer can satisfy the `CurrencyProvider` contract with an external HTTP
client and surface its identifier through `ConversionResult.providerName`:
```ts
import type { CurrencyProvider, RateFetchResult } from "@suaveplan/currency";
class EcbProvider implements CurrencyProvider {
readonly name = "ecb";
readonly supportsCrypto = false;
readonly supportsFiat = true;
async fetchRates({
base = "EUR",
targets,
signal,
} = {}): Promise<RateFetchResult> {
const res = await fetch(`https://api.example.com/${base}`, { signal });
const data = (await res.json()) as { rates: Record<string, number> };
let rates = data.rates;
if (targets) {
const filtered: Record<string, number> = {};
for (const t of targets) {
if (rates[t] !== undefined) filtered[t] = rates[t];
}
rates = filtered;
}
return { base, rates, timestamp: Date.now() };
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `CurrencyProvider` | `interface` | Rate-source contract — `name`, `supportsCrypto`, `supportsFiat`, optional `supportsVirtual`, `fetchRates`. |
| `RateFetchOptions` | `interface` | `base?`, `targets?`, `signal?`. |
| `RateFetchResult` | `interface` | `base`, `rates`, `timestamp`. |
| `ExchangeRate` | `interface` | Single-pair wire shape: `from`, `to`, `rate`, `timestamp`. |
| `VirtualCurrencyConfig` | `interface` | `rate`, `name`, `symbol`, optional `exponent` / `issuerId`. |
| `ConverterConfig` | `interface` | `provider`, optional `fallbackProviders`, `staleDurationMs`, `defaultBase`, `virtualCurrencies`. |
| `ConvertOptions` | `interface` | `rounding?`, `fresh?`, `signal?`. |
| `ConversionResult` | `interface` | `amount`, `rate`, `inverseRate`, `from`, `to`, `rateAgeMs`, `providerName`, `triangulatedThrough`. |
## Implementation Notes
Every field is `readonly` so consumers can confidently pass results around
without defensive copies; the converter never mutates a `RateFetchResult`
after handing it to the cache. `signal?: AbortSignal` is intentionally
optional on every options bundle so callers that have no cancellation source
need not synthesise one. `triangulatedThrough` is typed as
`readonly string[] | null` (not `string | null`) so future multi-hop BFS work
does not require a breaking type change; the migration baseline emits arrays
of length 0 or 1 only.