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 @@
export { CurrencyProviderRegistry } from "./registry.js";

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,158 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "../error-codes/error-codes.js";
import type { ApiKeyConfig, NoKeyConfig } from "../types/configs.js";
import { CurrencyProviderRegistry } from "./registry.js";
function fakeProvider(
name: string,
overrides?: Partial<CurrencyProvider>
): CurrencyProvider {
return {
name,
supportsCrypto: false,
supportsFiat: true,
async fetchRates(
_options?: RateFetchOptions
): Promise<RateFetchResult> {
return { base: "USD", rates: {}, timestamp: 0 };
},
...overrides,
};
}
describe("CurrencyProviderRegistry", () => {
it("registers and retrieves a provider", { timeout: 5000 }, () => {
const registry = new CurrencyProviderRegistry();
const p = fakeProvider("fake-fx");
registry.register(p);
expect(registry.get("fake-fx")).toBe(p);
expect(registry.has("fake-fx")).toBe(true);
});
it("raises PROVIDER_NOT_REGISTERED when get is called on an unknown name", {
timeout: 5000,
}, () => {
const registry = new CurrencyProviderRegistry();
try {
registry.get("never-registered");
expect.fail("get() should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
);
expect(err.providerName).toBe("never-registered");
}
});
it("raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name", {
timeout: 5000,
}, () => {
const registry = new CurrencyProviderRegistry();
registry.register(fakeProvider("dup"));
try {
registry.register(fakeProvider("dup"));
expect.fail("register() should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(
CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED
);
expect(err.providerName).toBe("dup");
}
});
it("has returns false for unregistered names", { timeout: 5000 }, () => {
const registry = new CurrencyProviderRegistry();
expect(registry.has("nope")).toBe(false);
registry.register(fakeProvider("yes"));
expect(registry.has("yes")).toBe(true);
expect(registry.has("nope")).toBe(false);
});
it("list returns a sorted snapshot", { timeout: 5000 }, () => {
const registry = new CurrencyProviderRegistry();
registry.register(fakeProvider("zeta"));
registry.register(fakeProvider("alpha"));
registry.register(fakeProvider("mu"));
expect(registry.list()).toEqual(["alpha", "mu", "zeta"]);
});
it("list returns an empty array for a fresh registry", {
timeout: 5000,
}, () => {
const registry = new CurrencyProviderRegistry();
expect(registry.list()).toEqual([]);
});
it("unregister removes and reports correctly", { timeout: 5000 }, () => {
const registry = new CurrencyProviderRegistry();
registry.register(fakeProvider("alpha"));
expect(registry.unregister("alpha")).toBe(true);
expect(registry.unregister("alpha")).toBe(false);
expect(registry.has("alpha")).toBe(false);
try {
registry.get("alpha");
expect.fail("get() should have thrown after unregister");
} catch (error) {
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
);
}
});
it("unregister of an unknown name returns false without throwing", {
timeout: 5000,
}, () => {
const registry = new CurrencyProviderRegistry();
expect(registry.unregister("never-registered")).toBe(false);
});
it("accepts a defineCurrencyProvider-produced provider via ApiKeyConfig contract", {
timeout: 5000,
}, () => {
// Type-only sanity check: a sibling package using ApiKeyConfig
// for its constructor should produce a provider that the registry
// accepts. We verify the constructor wiring compiles + the
// registry-stored shape carries the expected name.
function buildKeyedProvider(config: ApiKeyConfig): CurrencyProvider {
const _fetch: FetchLike =
config.fetch ?? (async () => new Response("{}"));
return fakeProvider("keyed", {
supportsCrypto: true,
supportsFiat: false,
});
}
function buildKeylessProvider(config: NoKeyConfig): CurrencyProvider {
const _fetch: FetchLike =
config.fetch ?? (async () => new Response("{}"));
return fakeProvider("keyless");
}
const registry = new CurrencyProviderRegistry();
registry.register(buildKeyedProvider({ apiKey: "abc" }));
registry.register(buildKeylessProvider({}));
expect(registry.list()).toEqual(["keyed", "keyless"]);
expect(registry.get("keyed").supportsCrypto).toBe(true);
expect(registry.get("keyless").supportsFiat).toBe(true);
});
});

View File

@@ -0,0 +1,77 @@
import type { CurrencyProvider } from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "../error-codes/error-codes.js";
/**
* In-memory registry of `CurrencyProvider` instances keyed by `provider.name`.
*
* Sibling `@suaveplan/currency-provider-<name>` packages register their
* provider with this registry; consumers (e.g. `CurrencyConverter` wiring,
* future `AggregatorProvider` / `RateLimitedProvider` wrappers) resolve
* providers by name without having to track concrete classes themselves.
*
* The registry stores providers by reference — no cloning, freezing, or
* defensive copy. Providers are expected to be immutable shells around their
* config.
*/
export class CurrencyProviderRegistry {
private readonly entries: Map<string, CurrencyProvider> = new Map();
/**
* Register a provider under its own `provider.name`. Throws
* `CurrencyProviderError(PROVIDER_ALREADY_REGISTERED)` when the name is
* already in use. Explicit replacement requires `unregister(name)` first.
*/
public register(provider: CurrencyProvider): void {
const name = provider.name;
if (this.entries.has(name)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
`Provider "${name}" is already registered`,
{ providerName: name }
);
}
this.entries.set(name, provider);
}
/**
* Look up a registered provider by name. Throws
* `CurrencyProviderError(PROVIDER_NOT_REGISTERED)` when no provider has
* been registered under `name`.
*/
public get(name: string): CurrencyProvider {
const provider = this.entries.get(name);
if (provider === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
`No provider registered under name "${name}"`,
{ providerName: name }
);
}
return provider;
}
/** Non-throwing probe — `true` when {@link get} would succeed. */
public has(name: string): boolean {
return this.entries.has(name);
}
/**
* Sorted snapshot of currently registered provider names. Equal sets of
* providers list identically regardless of insertion order, which keeps
* downstream golden-file tests deterministic.
*/
public list(): readonly string[] {
return [...this.entries.keys()].sort();
}
/**
* Remove a registration. Returns `true` when something was removed,
* `false` when no provider was registered under `name`. Never throws.
*/
public unregister(name: string): boolean {
return this.entries.delete(name);
}
}