Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# error-codes — typed error helpers for the virtual provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code
that `@suaveplan/currency-provider-virtual` raises, plus a tiny
`makeUnknownCurrencyError(base)` builder that constructs the typed
`CurrencyProviderError` with the canonical message format and
`providerName: "virtual-currency"` discriminant pre-populated. The full
8-code registry and the `CurrencyProviderError` class itself live in the
`@suaveplan/currency-providers` slim core — this module never adds new
codes, it only re-narrows the surface the virtual provider actually uses.
The `VIRTUAL_PROVIDER_NAME` constant is the stable identifier exposed as
`provider.name` and stamped onto every emitted error's `providerName`
discriminant, so consumer assertions can pin to a single source of truth.
## Features
- `VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` — re-export of the
slim-core `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code (string-identical).
- `VIRTUAL_PROVIDER_NAME` — `"virtual-currency"`.
- `makeUnknownCurrencyError(base)` — returns a `CurrencyProviderError`
with the canonical message and `providerName` discriminant set.
## Usage: Basic
```ts
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
const error = makeUnknownCurrencyError("NOPE");
console.log(error.code); // "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
console.log(error.providerName); // "virtual-currency"
```
## Usage: Advanced
```ts
import { CurrencyProviderError } from "@suaveplan/currency-providers";
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
function isUnknownVirtualCurrency(err: unknown): err is CurrencyProviderError {
return (
err instanceof CurrencyProviderError &&
err.code === VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY &&
err.providerName === "virtual-currency"
);
}
try {
throw makeUnknownCurrencyError("GHOST");
} catch (err) {
if (isUnknownVirtualCurrency(err)) {
// Fall back to the next provider in the chain.
} else {
throw err;
}
}
```
## API Reference
### `VIRTUAL_CURRENCY_PROVIDER_CODES`
Frozen record. Contains exactly one key — `UNKNOWN_CURRENCY` — whose
value is the namespaced string `"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
imported from the slim core. Reference-equal with the slim-core
constant; comparisons via `===` work as expected.
### `VIRTUAL_PROVIDER_NAME`
`"virtual-currency"`. Stable identifier used as `provider.name` and as
the `providerName` discriminant on every emitted error.
### `makeUnknownCurrencyError(base: string): CurrencyProviderError`
Builds the canonical error for a missing-base lookup.
- `base` — the unknown base currency code; included verbatim in the
message.
- Returns a `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`,
`providerName === "virtual-currency"`, and message format
`"VirtualCurrencyProvider: no rates stored for base \"<base>\""`.
## Implementation Notes
- This module owns zero codes — it re-exports one from the slim core.
Adding a new code requires an OpenSpec change against
`@suaveplan/currency-providers` (which holds the canonical registry),
not against this package.
- The helper is `const`-only — no top-level state, no I/O, no closures
captured — so it remains tree-shake-friendly under
`"sideEffects": false`.

View File

@@ -0,0 +1,55 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
VIRTUAL_PROVIDER_NAME,
} from "./error-codes.js";
describe("VIRTUAL_CURRENCY_PROVIDER_CODES", () => {
it("re-exports UNKNOWN_CURRENCY identical to the slim-core constant", {
timeout: 5000,
}, () => {
expect(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY).toBe(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
);
expect(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY).toBe(
"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
);
});
});
describe("VIRTUAL_PROVIDER_NAME", () => {
it("is the canonical virtual-currency name", { timeout: 5000 }, () => {
expect(VIRTUAL_PROVIDER_NAME).toBe("virtual-currency");
});
});
describe("makeUnknownCurrencyError", () => {
it("builds a CurrencyProviderError with the typed code and discriminants", {
timeout: 5000,
}, () => {
const err = makeUnknownCurrencyError("GHOST");
expect(err).toBeInstanceOf(CurrencyProviderError);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe(VIRTUAL_PROVIDER_NAME);
expect(err.message).toBe(
'VirtualCurrencyProvider: no rates stored for base "GHOST"'
);
expect(err.status).toBeUndefined();
expect(err.upstreamMessage).toBeUndefined();
});
it("includes the base verbatim in the message — even unusual codes", {
timeout: 5000,
}, () => {
const err = makeUnknownCurrencyError("USDC-GOLD");
expect(err.message).toContain('"USDC-GOLD"');
});
});

View File

@@ -0,0 +1,34 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
/**
* The single `CURRENCY_PROVIDER_*` namespaced code this package raises, surfaced
* locally for ergonomic access (so call sites do not have to import the full
* 8-code registry from the slim core just to assert on `UNKNOWN_CURRENCY`).
* The value is the same string constant produced by the slim core's
* `defineCodes` call — references compare identically.
*/
export const VIRTUAL_CURRENCY_PROVIDER_CODES = {
UNKNOWN_CURRENCY: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
} as const;
/** Stable identifier used as `provider.name` and on every `providerName`
* discriminant of an emitted `CurrencyProviderError`. */
export const VIRTUAL_PROVIDER_NAME = "virtual-currency";
/**
* Build the `CurrencyProviderError(UNKNOWN_CURRENCY)` raised by
* `VirtualCurrencyProvider.fetchRates` when the requested base has no
* rates in the table. Extracted into a tiny helper so the provider's call
* site stays a one-liner and the error-construction path is covered by a
* dedicated unit test rather than only the integration scenario.
*/
export function makeUnknownCurrencyError(base: string): CurrencyProviderError {
return new CurrencyProviderError(
VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`VirtualCurrencyProvider: no rates stored for base "${base}"`,
{ providerName: VIRTUAL_PROVIDER_NAME }
);
}

View File

@@ -0,0 +1,10 @@
export {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
VIRTUAL_PROVIDER_NAME,
} from "./error-codes/error-codes.js";
export { VirtualCurrencyProvider } from "./provider/provider.js";
export type {
VirtualCurrencyProviderConfig,
VirtualRateEntry,
} from "./provider/provider.types.js";

View File

@@ -0,0 +1,5 @@
export { VirtualCurrencyProvider } from "./provider.js";
export type {
VirtualCurrencyProviderConfig,
VirtualRateEntry,
} from "./provider.types.js";

View File

@@ -0,0 +1,160 @@
# provider — VirtualCurrencyProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VirtualCurrencyProvider` is an in-memory, programmable rate-source
implementation of `@suaveplan/currency`'s `CurrencyProvider` contract.
It serves operator-defined virtual / game currencies (and any internal
test rates) without ever issuing an HTTP request or consuming a
`FetchLike`. Rates are stored as a nested `Map<from, Map<to, number>>`
and mutated at runtime through `setRate`, `removeRate`, and `clear`.
The provider sets `supportsVirtual: true` and is the only sibling in
the `@suaveplan/currency-provider-*` family that does not depend on the
slim core's `httpRequest` helper. Use it for game economies, loyalty
points, internal test fixtures, or any rate table you want to control
deterministically.
## Features
- `setRate(from, to, rate)` — upsert a directed rate; replaces an
existing pair without throwing.
- `removeRate(from, to): boolean` — idempotent removal; returns whether
anything was actually removed. Auto-cleans the inner map when the
last rate for a `from` is dropped.
- `clear()` — empty the table in one call.
- `hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
`listCurrencies(): readonly string[]` — read-only helpers.
- `fetchRates(options?)` — returns the rates stored under
`options.base` (default `defaultBase`), optionally filtered by an
`options.targets` inclusion list. Throws
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when the
requested base is not in the table.
- Optional `Clock` injection for deterministic `timestamp` values.
- Universal — zero browser / Node / DOM APIs, `sideEffects: false`.
## Usage: Basic
```ts
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const result = await provider.fetchRates();
// { base: "USD", rates: { GOLD: 100, GEMS: 500 }, timestamp: <Date.now()> }
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
import { CURRENCY_PROVIDER_CODES, CurrencyProviderError } from "@suaveplan/currency-providers";
// Deterministic timestamps + partial-graph rates (no triangulation).
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
const provider = new VirtualCurrencyProvider({
defaultBase: "GOLD",
clock: () => fakeClock.now(),
});
provider.setRate("GOLD", "GEMS", 5);
provider.setRate("GOLD", "USD", 0.01);
const golden = await provider.fetchRates({ base: "GOLD", targets: ["GEMS"] });
// { base: "GOLD", rates: { GEMS: 5 }, timestamp: 1_700_000_000_000 }
fakeClock.advance(1000);
const stale = await provider.fetchRates({ base: "GOLD" });
// { base: "GOLD", rates: { GEMS: 5, USD: 0.01 }, timestamp: 1_700_000_001_000 }
// Operator updates a rate at runtime — next fetchRates picks it up.
provider.setRate("GOLD", "GEMS", 6);
// Unknown base raises the typed error.
try {
await provider.fetchRates({ base: "GHOST" });
} catch (err) {
if (
err instanceof CurrencyProviderError &&
err.code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
) {
// expected
}
}
```
## API Reference
### `class VirtualCurrencyProvider implements CurrencyProvider`
Constructor: `new VirtualCurrencyProvider(config?: VirtualCurrencyProviderConfig)`.
Static identity:
- `name === "virtual-currency"`
- `supportsCrypto === false`
- `supportsFiat === false`
- `supportsVirtual === true`
Methods:
- `setRate(from: string, to: string, rate: number): void` — upsert. No
throw on duplicate pair.
- `removeRate(from: string, to: string): boolean` — idempotent. Never
throws.
- `clear(): void` — empties the table.
- `hasRate(from: string, to: string): boolean`
- `getRate(from: string, to: string): number | undefined`
- `listCurrencies(): readonly string[]` — sorted snapshot of every
`from` key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
rejects with `CurrencyProviderError(UNKNOWN_CURRENCY)` when the
requested base is not stored; otherwise resolves to the rate map for
that base, filtered by `options.targets` when provided, with
`timestamp = clock()`.
### `interface VirtualCurrencyProviderConfig`
- `defaultBase?: string` — base used when `fetchRates()` omits
`options.base`. Defaults to `"USD"`.
- `initialRates?: readonly VirtualRateEntry[]` — entries upserted at
construction. Duplicates within the array are last-write-wins.
- `clock?: Clock` — `() => number` (ms since epoch). Defaults to
`() => Date.now()`.
### `interface VirtualRateEntry`
`{ from: string; to: string; rate: number }`.
## Implementation Notes
- The rate table is `Map<from, Map<to, number>>` — O(1) lookup,
O(n) enumeration over the inner map. The flat alternative
(`Map<"${from}->${to}", number>`) was rejected because currency codes
can contain `-` (e.g. `USDC-GOLD`, `GAME-1-COIN`) which would create
ambiguous keys.
- `removeRate` deletes the empty inner map after the last pair under a
`from` is removed, so `listCurrencies()` stays accurate without a
separate compaction pass.
- The `fetchRates` "no I/O" promise wrapping satisfies the
`CurrencyProvider` async contract without introducing a microtask
delay observable to callers; the work is purely synchronous.
- `Clock` is invoked once per `fetchRates` call, at the moment of the
call (not construction). This matches the wall-clock semantics
every other provider in the family uses.
- The provider is intentionally side-effect-free in its constructor
(unlike the upstream form which called `@suaveplan/money`
`registerCurrency`) — keeping `"sideEffects": false` honest and
tree-shake safe.

View File

@@ -0,0 +1,275 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VirtualCurrencyProvider } from "./provider.js";
describe("VirtualCurrencyProvider — identity", () => {
it("surfaces the expected identity flags", { timeout: 5000 }, () => {
const provider = new VirtualCurrencyProvider();
expect(provider.name).toBe("virtual-currency");
expect(provider.supportsCrypto).toBe(false);
expect(provider.supportsFiat).toBe(false);
expect(provider.supportsVirtual).toBe(true);
});
it("has no FetchLike or fetch references in production source", {
timeout: 5000,
}, () => {
// Static check: the provider module never imports or invokes
// FetchLike / globalThis.fetch / fetch(. Reading the file is enough —
// the assertion is a structural guarantee, not a runtime trace.
const sourceFiles = [
join(__dirname, "provider.ts"),
join(__dirname, "provider.types.ts"),
join(__dirname, "index.ts"),
];
const offending: string[] = [];
for (const path of sourceFiles) {
const raw = readFileSync(path, "utf8");
// Strip block comments and line comments so the negative assertion
// only catches real code references, not docstring mentions.
const code = raw
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
if (/\bFetchLike\b|globalThis\.fetch|\bfetch\s*\(/.test(code)) {
offending.push(path);
}
}
expect(offending).toEqual([]);
});
});
describe("VirtualCurrencyProvider — programmable rate-table API", () => {
it("setRate upserts an existing pair without throwing", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
provider.setRate("USD", "GOLD", 200);
expect(provider.getRate("USD", "GOLD")).toBe(200);
});
it("removeRate returns true on first call and false on the second", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
expect(provider.removeRate("USD", "GOLD")).toBe(true);
expect(provider.removeRate("USD", "GOLD")).toBe(false);
expect(provider.hasRate("USD", "GOLD")).toBe(false);
});
it("removeRate is a no-op for an unknown from", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
expect(provider.removeRate("UNKNOWN", "GOLD")).toBe(false);
});
it("removeRate keeps the from entry when other targets remain under it", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
provider.setRate("USD", "GEMS", 500);
expect(provider.removeRate("USD", "GOLD")).toBe(true);
// USD still has GEMS — it must stay in listCurrencies.
expect(provider.listCurrencies()).toEqual(["USD"]);
expect(provider.hasRate("USD", "GEMS")).toBe(true);
});
it("removeRate cleans up empty inner maps so listCurrencies stays accurate", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
provider.removeRate("USD", "GOLD");
expect(provider.listCurrencies()).toEqual([]);
});
it("clear empties the rate table", { timeout: 5000 }, () => {
const provider = new VirtualCurrencyProvider({
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "EUR", to: "GEMS", rate: 50 },
],
});
provider.clear();
expect(provider.listCurrencies()).toEqual([]);
expect(provider.hasRate("USD", "GOLD")).toBe(false);
expect(provider.hasRate("EUR", "GEMS")).toBe(false);
});
it("hasRate and getRate report pair state correctly", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
expect(provider.hasRate("USD", "GOLD")).toBe(true);
expect(provider.hasRate("USD", "GEMS")).toBe(false);
expect(provider.hasRate("EUR", "GOLD")).toBe(false);
expect(provider.getRate("USD", "GOLD")).toBe(100);
expect(provider.getRate("USD", "MISSING")).toBeUndefined();
expect(provider.getRate("MISSING", "GOLD")).toBeUndefined();
});
it("listCurrencies returns a lexicographic snapshot", {
timeout: 5000,
}, () => {
const provider = new VirtualCurrencyProvider();
provider.setRate("USD", "GOLD", 100);
provider.setRate("GOLD", "GEMS", 5);
provider.setRate("EUR", "GOLD", 90);
expect(provider.listCurrencies()).toEqual(["EUR", "GOLD", "USD"]);
});
});
describe("VirtualCurrencyProvider — fetchRates", () => {
it("returns all rates for the default base", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(result.rates).toEqual({ GOLD: 100, GEMS: 500 });
expect(typeof result.timestamp).toBe("number");
});
it("uses the configured defaultBase fallback when no config supplied", {
timeout: 5000,
}, async () => {
// No defaultBase passed — implementation defaults to "USD".
const provider = new VirtualCurrencyProvider({
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
});
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(result.rates).toEqual({ GOLD: 100 });
});
it("filters returned rates by the targets inclusion list", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
{ from: "USD", to: "SILVER", rate: 50 },
],
});
const result = await provider.fetchRates({
base: "USD",
targets: ["GOLD"],
});
expect(result.rates).toEqual({ GOLD: 100 });
});
it("honours an explicit non-default base", { timeout: 5000 }, async () => {
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "GOLD", to: "GEMS", rate: 5 },
{ from: "GOLD", to: "USD", rate: 0.01 },
],
});
const result = await provider.fetchRates({ base: "GOLD" });
expect(result.base).toBe("GOLD");
expect(result.rates).toEqual({ GEMS: 5, USD: 0.01 });
});
it("returns an empty rate map when the targets filter excludes every stored target", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
});
const result = await provider.fetchRates({
base: "USD",
targets: ["MISSING"],
});
expect(result.rates).toEqual({});
});
});
describe("VirtualCurrencyProvider — UNKNOWN_CURRENCY", () => {
it("throws CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is missing", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider();
await expect(
provider.fetchRates({ base: "NOPE" })
).rejects.toBeInstanceOf(CurrencyProviderError);
try {
await provider.fetchRates({ base: "NOPE" });
throw new Error("expected rejection");
} catch (err) {
expect(err).toBeInstanceOf(CurrencyProviderError);
const typed = err as CurrencyProviderError;
expect(typed.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(typed.providerName).toBe("virtual-currency");
}
});
it("throws UNKNOWN_CURRENCY when the default base has no stored rates", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider({ defaultBase: "USD" });
try {
await provider.fetchRates();
throw new Error("expected rejection");
} catch (err) {
expect(err).toBeInstanceOf(CurrencyProviderError);
const typed = err as CurrencyProviderError;
expect(typed.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
}
});
});
describe("VirtualCurrencyProvider — Clock injection", () => {
it("FakeClock-driven timestamps are deterministic across calls", {
timeout: 5000,
}, async () => {
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
clock: () => fakeClock.now(),
});
const first = await provider.fetchRates();
expect(first.timestamp).toBe(1_700_000_000_000);
fakeClock.advance(1000);
const second = await provider.fetchRates();
expect(second.timestamp).toBe(1_700_000_001_000);
});
it("defaults to Date.now when no clock is injected", {
timeout: 5000,
}, async () => {
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
});
const before = Date.now();
const result = await provider.fetchRates();
const after = Date.now();
expect(result.timestamp).toBeGreaterThanOrEqual(before);
expect(result.timestamp).toBeLessThanOrEqual(after);
});
});

View File

@@ -0,0 +1,139 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import type { Clock } from "@suaveplan/types";
import {
makeUnknownCurrencyError,
VIRTUAL_PROVIDER_NAME,
} from "../error-codes/error-codes.js";
import type { VirtualCurrencyProviderConfig } from "./provider.types.js";
const DEFAULT_BASE = "USD";
/**
* In-memory programmable virtual / game currency provider implementing
* the `CurrencyProvider` contract from `@suaveplan/currency`. No HTTP,
* no API key, no `FetchLike`. The rate table is a nested
* `Map<from, Map<to, number>>` populated at construction and mutable at
* runtime via `setRate` / `removeRate` / `clear`.
*
* Triangulation is deliberately absent — the provider returns only the
* directed rates explicitly stored under the requested `base`. Callers
* that need bidirectional or triangulated rates pre-populate them with
* `setRate`, or wire the provider through `CurrencyConverter` which
* triangulates from its cache.
*/
export class VirtualCurrencyProvider implements CurrencyProvider {
public readonly name = VIRTUAL_PROVIDER_NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = false;
public readonly supportsVirtual = true;
private readonly defaultBase: string;
private readonly clock: Clock;
private readonly table: Map<string, Map<string, number>> = new Map();
public constructor(config: VirtualCurrencyProviderConfig = {}) {
this.defaultBase = config.defaultBase ?? DEFAULT_BASE;
this.clock = config.clock ?? (() => Date.now());
for (const entry of config.initialRates ?? []) {
this.setRate(entry.from, entry.to, entry.rate);
}
}
/**
* Upsert a directed rate. Calling twice for the same `(from, to)`
* pair replaces the previous value without throwing.
*/
public setRate(from: string, to: string, rate: number): void {
let inner = this.table.get(from);
if (inner === undefined) {
inner = new Map();
this.table.set(from, inner);
}
inner.set(to, rate);
}
/**
* Remove a directed rate. Returns `true` when a rate was removed,
* `false` otherwise. Cleans up the inner map when the last rate for
* a `from` is dropped, so `listCurrencies()` stays accurate. Never
* throws.
*/
public removeRate(from: string, to: string): boolean {
const inner = this.table.get(from);
if (inner === undefined) return false;
const removed = inner.delete(to);
if (removed && inner.size === 0) {
this.table.delete(from);
}
return removed;
}
/** Empty the entire rate table. */
public clear(): void {
this.table.clear();
}
/** Non-throwing probe — `true` when {@link getRate} would return a number. */
public hasRate(from: string, to: string): boolean {
return this.table.get(from)?.has(to) ?? false;
}
/** Direct rate lookup, or `undefined` when the pair is unknown. */
public getRate(from: string, to: string): number | undefined {
return this.table.get(from)?.get(to);
}
/**
* Sorted snapshot of every currency that appears as a `from` in the
* table. Lexicographic order — equal sets produce identical lists
* regardless of insertion order.
*/
public listCurrencies(): readonly string[] {
return [...this.table.keys()].sort();
}
/**
* Returns the rates stored under `options.base` (or the configured
* `defaultBase`), filtered by `options.targets` when present. Throws
* `CurrencyProviderError(UNKNOWN_CURRENCY)` when the requested base
* has no rates stored. The promise wrapping satisfies the
* `CurrencyProvider` contract; no I/O occurs.
*/
public fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
const base = options?.base ?? this.defaultBase;
const inner = this.table.get(base);
if (inner === undefined) {
return Promise.reject(makeUnknownCurrencyError(base));
}
const rates = filterRates(inner, options?.targets);
return Promise.resolve({
base,
rates,
timestamp: this.clock(),
});
}
}
function filterRates(
inner: ReadonlyMap<string, number>,
targets: readonly string[] | undefined
): Readonly<Record<string, number>> {
const out: Record<string, number> = {};
if (targets === undefined) {
for (const [code, rate] of inner) {
out[code] = rate;
}
return out;
}
const targetSet = new Set(targets);
for (const [code, rate] of inner) {
if (targetSet.has(code)) {
out[code] = rate;
}
}
return out;
}

View File

@@ -0,0 +1,45 @@
import type { Clock } from "@suaveplan/types";
/**
* Initial rate-table entry accepted by the {@link VirtualCurrencyProvider}
* constructor. The provider stores rates as directed `from -> to` pairs so
* partial graphs (only some pairs known) and arbitrary virtual-virtual
* conversions are first-class — there is no implicit shared base.
*/
export interface VirtualRateEntry {
/** Source currency code. */
readonly from: string;
/** Target currency code. */
readonly to: string;
/** Conversion rate (1 unit of `from` = `rate` units of `to`). */
readonly rate: number;
}
/**
* Configuration for the {@link VirtualCurrencyProvider} constructor.
*
* All fields are optional. A provider constructed with no config is a
* valid (empty) instance — callers populate the rate table via
* `setRate` and `fetchRates({ base })` will raise
* `CurrencyProviderError(UNKNOWN_CURRENCY)` until a matching `from`
* has been seeded.
*/
export interface VirtualCurrencyProviderConfig {
/**
* Default `base` used by `fetchRates()` when the caller omits
* `options.base`. Defaults to `"USD"`.
*/
readonly defaultBase?: string;
/**
* Initial rate-table entries. Each entry is upserted via the same
* code path as `setRate`, so duplicates within the array silently
* replace earlier values (last-write-wins).
*/
readonly initialRates?: readonly VirtualRateEntry[];
/**
* Optional wall-clock source for `RateFetchResult.timestamp`. Defaults
* to `() => Date.now()`. Tests inject a `FakeClock`-backed function
* for deterministic timestamps.
*/
readonly clock?: Clock;
}