Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
# Design: @suaveplan/currency-provider-virtual
> Non-normative. The normative requirements live in
> `./specs/currency-provider-virtual/spec.md`. The cross-cutting
> foundation-with-sibling-plugins architecture is documented once in
> `openspec/archive/2026-05-17-slim-currency-providers/design.md` — this
> document covers only what is specific to the virtual sibling.
## Shape
```text
@suaveplan/currency-provider-virtual
└── src/
├── index.ts # explicit named barrel
├── error-codes/
│ ├── error-codes.ts # re-exports UNKNOWN_CURRENCY helper
│ └── error-codes.md
└── provider/
├── provider.ts # VirtualCurrencyProvider class
├── provider.types.ts # rate-table & options types
├── provider.test.ts # all behaviour incl. UNKNOWN_CURRENCY
└── provider.md
```
## Rate-table storage
Rates are stored as a nested `Map<from, Map<to, number>>`. Pair lookup is
O(1); enumeration for `fetchRates` is O(n) over the inner map of the
requested base. Storing the table as nested `Map`s rather than a flat
`Map<"${from}->${to}", number>` keeps `listCurrencies` cheap (just the outer
key set) and avoids string-concatenation collisions when currency codes
contain hyphens (`USDC-GOLD`, `GAME-1-COIN`).
Insertion replaces an existing rate silently — `setRate` is upsert, mirroring
the upstream behaviour. This is intentional: virtual currencies are
operator-controlled and "the operator changed the rate" is the normal flow,
not an error.
## Triangulation is deliberately absent
The upstream provider derived `rates[base]` for non-default bases by inverting
the stored rate (`1 / baseVc.rate`) and computing cross-rates
(`otherRate / baseVc.rate`). Genesis pushes that into `CurrencyConverter`:
the provider returns the rates it has, the converter triangulates via the
cache. This keeps two responsibilities separate and avoids the upstream's
implicit assumption that every virtual currency converts through a single
shared `EUR` base. Callers who want bidirectional rates pre-populate them
explicitly — usually trivial in a game-economy seeder.
## Clock injection
`Clock = () => number` from `@suaveplan/types` (ms since epoch). The
constructor accepts an optional `clock` (default `() => Date.now()`); every
`fetchRates` result stamps `timestamp = clock()`. Tests pass a `FakeClock`
adapter (`() => fakeClock.now()`) for deterministic timestamps without
monkey-patching `Date`.
## Error handling
`fetchRates({ base })` consults the rate table for `base`. If `base` is not
present, the provider throws
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` with
`providerName: "virtual-currency"`. This is the only error path: `setRate`
upserts, `removeRate` is idempotent (returns `false` for missing pairs),
`clear` always succeeds. `UNKNOWN_CURRENCY` was added to the slim-core
registry on the original 2026-05-17 land and is the canonical typed error
for "asked about a currency the provider doesn't know."
## Universal-tier purity
- No `node:` / `bun:` / DOM imports anywhere in `src/`.
- No `fetch`, no network, no `FetchLike` consumption — this is the entire
point of the package.
- `sideEffects: false` — constructing the provider never mutates global
state. (Contrast with the upstream form which called
`@suaveplan/money` `registerCurrency` from the constructor, a hidden side
effect that broke tree-shaking.)

View File

@@ -0,0 +1,102 @@
# Add @suaveplan/currency-provider-virtual
**Package**: `@suaveplan/currency-provider-virtual`
**Status**: PROPOSED
**Type**: new package (sibling of `@suaveplan/currency-providers` slim core)
- **Design**: ./design.md
- **Spec delta**: ./specs/currency-provider-virtual/spec.md
## Why
The 2026-05-17 `slim-currency-providers` refactor split the original 9-provider
bundle into a slim core (`@suaveplan/currency-providers`) plus nine sibling
`@suaveplan/currency-provider-<name>` packages. Of the nine, eight wrap remote
HTTP APIs (ECB, Fixer, OXR, Binance, CoinGecko, CoinMarketCap, CryptoCompare,
Kraken); the ninth — **virtual** — serves operator-defined virtual / game
currencies whose rates are programmable in memory and never round-trip a
network.
This change lands the virtual sibling. It is the only sibling that does NOT
consume `httpRequest` / `FetchLike` from the slim core (it has no upstream to
fetch) and that DOES set `supportsVirtual: true` on the
`CurrencyProvider` contract.
## What changes
### ADDED
- New package `@suaveplan/currency-provider-virtual` at
`packages/finance/currency-provider-virtual/` (universal-tier, `0.1.0`).
- `VirtualCurrencyProvider` — implements `CurrencyProvider` from
`@suaveplan/currency`. Constructor takes an initial rate table and an
optional `Clock` (default `Date.now`) used for `fetchRates` timestamps. No
HTTP, no API key, no `FetchLike`.
- Programmable rate-table API: `setRate(from, to, rate)`,
`removeRate(from, to): boolean`, `clear()`, plus the read helpers
`hasRate(from, to)`, `getRate(from, to)`, `listCurrencies(): readonly string[]`.
- `fetchRates(options?)` — returns every rate denominated against the
requested `base` (default the provider's `defaultBase`), filtered by the
optional `targets` set, with `timestamp` derived from the injected `Clock`.
- Raises `CurrencyProviderError(UNKNOWN_CURRENCY)` (registered in
`@suaveplan/currency-providers`) when `fetchRates` is asked for a base that
is not present in the rate table — there is no triangulation, the registry
can be empty.
### KEPT (inherited)
- `CurrencyProvider` contract from `@suaveplan/currency` (unchanged).
- `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` from
`@suaveplan/currency-providers` (already present in the 8-code slim-core
registry).
- Universal-tier purity (zero browser / Node APIs in `src/`), explicit named
barrels, `@suaveplan/testing/runner` imports, 100/100/100/100 istanbul
coverage.
## Source mapping
The upstream reference shape lived in
`../suaveplan/packages/finance/currency-providers/src/virtual/virtual.ts`
(pre-slim, now removed). That upstream shape coupled the provider to
`@suaveplan/money` (`registerCurrency`) and stored rates as a record of
`VirtualCurrencyConfig { rate, name, symbol, exponent?, issuerId? }` keyed by
currency code, assuming a single shared base.
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Programmable per-currency rate table | ✅ | ✅ | Surface generalised — `setRate(from, to, rate)` lets callers store arbitrary directed pairs (game internal rates, partial graphs) rather than only "one base, many targets". |
| `fetchRates({ base, targets })` | ✅ | ✅ | |
| `supportsVirtual: true` flag | ✅ | ✅ | |
| Triangulation from any virtual base | ✅ | 🚫 | Scoped out: the slim-core / `CurrencyConverter` already handles triangulation via the rate cache; duplicating it here would split the algorithm across two packages. Callers that need triangulated rates either pre-populate both directions with `setRate` or wire the provider through `CurrencyConverter` which triangulates from the cached `RateFetchResult`. |
| Auto-registering currencies via `@suaveplan/money` `registerCurrency` | ✅ | 🚫 | Scoped out: the slim refactor explicitly dropped the `@suaveplan/money` dep so the slim core stays universal and zero-side-effect. Callers that want money-registry integration call `registerCurrency` themselves before constructing the provider — keeps this package side-effect-free per `sideEffects: false`. |
| `Clock`-injected `timestamp` for deterministic tests | ⬆️ | ⬆️ | Enhanced: upstream hard-coded `Date.now()`. We inject a `Clock` so `FakeClock`-driven tests get deterministic timestamps without monkey-patching `Date`. |
| `UNKNOWN_CURRENCY` typed error | ⬆️ | ⬆️ | Enhanced: upstream threw plain `Error("VirtualCurrencyProvider: no rates for base UNKNOWN")`. We throw `CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` with `providerName` populated. |
| `defineCurrencyProvider`-friendly | ⬆️ | ⬆️ | Enhanced: the provider exposes a stable class with the programmable methods AND can be wrapped via `defineCurrencyProvider` if a consumer prefers the object-literal idiom. |
The enterprise audit reference is
`../suaveplan/enterprise/currency-providers.md`; the virtual provider is one
row in that audit and carried no specific gaps beyond the registry-typed-error
gap, which is closed here.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual` to enumerate.
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual`).
The package is a leaf sibling — the future commerce / gaming stack downstream
will consume it directly, but those packages are still pending migration.
## Out of scope
- Triangulation across the rate table (see Parity Checklist row).
- `@suaveplan/money` `registerCurrency` integration (see Parity Checklist row).
- Persistence — the rate table is purely in-memory by design (this provider's
whole purpose). Callers that need persistence layer a storage adapter on top
and reload the table at construction.
- `AbortSignal` plumbing on `fetchRates` — the operation is synchronous over
an in-memory `Map`; an injected `signal` would have nothing to abort. The
optional `signal` from `RateFetchOptions` is accepted by the type but
unused.

View File

@@ -0,0 +1,196 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-virtual (add-currency-provider-virtual)
**Package**: `@suaveplan/currency-provider-virtual`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> No exceptions. This is a fresh package — only `## ADDED Requirements` apply.
## ADDED Requirements
### Requirement: VirtualCurrencyProvider implements CurrencyProvider with virtual support
The package SHALL export a `VirtualCurrencyProvider` class that implements
the `CurrencyProvider` contract from `@suaveplan/currency`. The provider
SHALL expose `name === "virtual-currency"`, `supportsCrypto === false`,
`supportsFiat === false`, and `supportsVirtual === true`. The provider
SHALL NOT consume `FetchLike`, SHALL NOT issue HTTP requests, and SHALL
NOT depend on `@suaveplan/money` — it is a pure in-memory rate source.
#### Scenario: surfaces the expected identity flags
- **WHEN** a `VirtualCurrencyProvider` is constructed
- **THEN** `provider.name === "virtual-currency"`,
`provider.supportsCrypto === false`,
`provider.supportsFiat === false`,
`provider.supportsVirtual === true`
#### Scenario: package has no FetchLike consumption
- **WHEN** `grep -rn "FetchLike\|globalThis.fetch\|fetch(" src/` is run
- **THEN** the output contains zero matches (no HTTP code paths exist)
**Verified by:** `src/provider/provider.test.ts::"surfaces the expected identity flags"` AND `src/provider/provider.test.ts::"has no FetchLike or fetch references in production source"`
---
### Requirement: Programmable rate-table API
The provider SHALL expose a programmable rate table via `setRate(from,
to, rate)`, `removeRate(from, to): boolean`, `clear(): void`,
`hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
and `listCurrencies(): readonly string[]`. `setRate` SHALL be upsert —
calling it twice for the same `(from, to)` pair replaces the previous
rate without throwing. `removeRate` SHALL return `true` when a pair was
removed and `false` otherwise, and SHALL NOT throw on unknown pairs.
`clear` SHALL empty the entire rate table. `listCurrencies` SHALL return
a sorted snapshot (lexicographic) of every currency that appears as a
`from` in the table.
#### Scenario: setRate upserts rates
- **WHEN** `setRate("USD", "GOLD", 100)` is called and then
`setRate("USD", "GOLD", 200)` is called
- **THEN** `getRate("USD", "GOLD") === 200` (the second call replaced
the first without throwing)
#### Scenario: removeRate is idempotent
- **WHEN** `setRate("USD", "GOLD", 100)` is followed by two calls to
`removeRate("USD", "GOLD")`
- **THEN** the first call returns `true`, the second returns `false`,
and `hasRate("USD", "GOLD") === false`
#### Scenario: clear empties the table
- **WHEN** several rates are set and `clear()` is called
- **THEN** `listCurrencies()` returns `[]` and `hasRate(...)` returns
`false` for every previously-known pair
#### Scenario: hasRate and getRate report pair state
- **WHEN** `setRate("USD", "GOLD", 100)` is called
- **THEN** `hasRate("USD", "GOLD") === true`,
`hasRate("USD", "GEMS") === false`,
`getRate("USD", "GOLD") === 100`,
`getRate("USD", "MISSING") === undefined`
#### Scenario: listCurrencies returns a sorted snapshot
- **WHEN** rates are set with `from` values `"USD"`, `"GOLD"`, `"EUR"`
in any order
- **THEN** `listCurrencies()` returns `["EUR", "GOLD", "USD"]`
(lexicographic order, regardless of insertion order)
**Verified by:** `src/provider/provider.test.ts::"setRate upserts an existing pair without throwing"` AND `src/provider/provider.test.ts::"removeRate returns true on first call and false on the second"` AND `src/provider/provider.test.ts::"clear empties the rate table"` AND `src/provider/provider.test.ts::"hasRate and getRate report pair state correctly"` AND `src/provider/provider.test.ts::"listCurrencies returns a lexicographic snapshot"`
---
### Requirement: fetchRates returns the rates for the requested base
The provider SHALL implement `fetchRates(options?: RateFetchOptions):
Promise<RateFetchResult>`. The returned `result.base` SHALL be
`options?.base` when provided, otherwise the provider's configured
`defaultBase`. The returned `result.rates` SHALL contain exactly the
rates stored under that base in the rate table, optionally filtered by
`options?.targets` (an inclusion list — only matching targets appear).
`result.timestamp` SHALL be the value returned by the injected `Clock`
at the moment of the call. The provider SHALL NOT triangulate — it
returns only the rates explicitly stored under the requested base.
#### Scenario: returns all rates for the default base
- **WHEN** the provider is constructed with `defaultBase: "USD"` and
rates `setRate("USD", "GOLD", 100)`, `setRate("USD", "GEMS", 500)`,
and `fetchRates()` is called
- **THEN** the promise resolves to a result with `base: "USD"`,
`rates: { GOLD: 100, GEMS: 500 }`
#### Scenario: filters by targets
- **WHEN** the provider holds rates for `GOLD`, `GEMS`, and `SILVER`
under `"USD"`, and `fetchRates({ base: "USD", targets: ["GOLD"] })`
is called
- **THEN** the resolved `rates` contains exactly `{ GOLD: 100 }` (the
other targets are absent)
#### Scenario: honours an explicit non-default base
- **WHEN** `fetchRates({ base: "GOLD" })` is called against a provider
that has rates set with `setRate("GOLD", "GEMS", 5)` and
`setRate("GOLD", "USD", 0.01)`
- **THEN** the resolved `result.base === "GOLD"` and the resolved
`rates` contains exactly `{ GEMS: 5, USD: 0.01 }`
#### Scenario: returns an empty rate map when the base has no targets
- **WHEN** `setRate("USD", "GOLD", 100)` is called and
`fetchRates({ base: "USD", targets: ["MISSING"] })` is called
- **THEN** the resolved `rates` is `{}` (no `targets` matched, the call
does not throw — `MISSING` simply isn't in the inclusion list)
**Verified by:** `src/provider/provider.test.ts::"returns all rates for the default base"` AND `src/provider/provider.test.ts::"filters returned rates by the targets inclusion list"` AND `src/provider/provider.test.ts::"honours an explicit non-default base"` AND `src/provider/provider.test.ts::"returns an empty rate map when the targets filter excludes every stored target"`
---
### Requirement: UNKNOWN_CURRENCY raised for missing base
The provider SHALL throw
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when
`fetchRates({ base })` is called for a `base` that is not present in
the rate table (and the table is therefore unable to answer for that
base). The thrown error SHALL carry `providerName === "virtual-currency"`.
The provider SHALL NOT throw a raw `Error`, and SHALL NOT triangulate
to satisfy an unknown base — the registry can be empty, and an unknown
base is a definitive "no answer" path.
#### Scenario: throws CurrencyProviderError(UNKNOWN_CURRENCY) when base is missing
- **WHEN** a fresh `VirtualCurrencyProvider` (empty table) has
`fetchRates({ base: "NOPE" })` called on it
- **THEN** the promise rejects with a `CurrencyProviderError` whose
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and whose
`providerName === "virtual-currency"`
#### Scenario: throws UNKNOWN_CURRENCY when the default base is empty
- **WHEN** the provider was constructed with `defaultBase: "USD"` and
no `USD`-rooted rates have ever been set, and `fetchRates()` (no
options) is called
- **THEN** the promise rejects with `CurrencyProviderError` whose
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
**Verified by:** `src/provider/provider.test.ts::"throws CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is missing"` AND `src/provider/provider.test.ts::"throws UNKNOWN_CURRENCY when the default base has no stored rates"`
---
### Requirement: Clock injection for deterministic timestamps
The provider constructor SHALL accept an optional `clock: Clock`
(`@suaveplan/types`'s `Clock = () => number`) and SHALL use it to
populate `RateFetchResult.timestamp` on every `fetchRates` call. When
no `clock` is provided the provider SHALL default to
`() => Date.now()`. The provider SHALL call the clock at the moment of
the `fetchRates` invocation, not at construction.
#### Scenario: FakeClock-driven timestamps are deterministic across calls
- **WHEN** the provider is constructed with `clock: () => fakeClock.now()`
where `fakeClock` is a `FakeClock({ startTime: 1_700_000_000_000 })`,
and `fetchRates()` is called twice with `fakeClock.advance(1000)`
between them
- **THEN** the first result's `timestamp === 1_700_000_000_000` and the
second result's `timestamp === 1_700_000_001_000`
#### Scenario: default clock falls back to Date.now
- **WHEN** the provider is constructed without a `clock` and
`fetchRates()` is called
- **THEN** the returned `timestamp` is a finite number whose value is
within ±1000 ms of `Date.now()` measured immediately around the call
**Verified by:** `src/provider/provider.test.ts::"FakeClock-driven timestamps are deterministic across calls"` AND `src/provider/provider.test.ts::"defaults to Date.now when no clock is injected"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,81 @@
# Tasks — Add @suaveplan/currency-provider-virtual
## Spec-completeness preflight (AGENTS.md §12a)
- [x] Numbered checklist of every `### Requirement` and `#### Scenario`
drafted in `specs/currency-provider-virtual/spec.md` (5 requirements,
16 scenarios).
## Package scaffold
- [x] `packages/finance/currency-provider-virtual/package.json` (0.0.0,
universal, `sideEffects: false`)
- [x] `tsconfig.json`, `tsconfig.lib.json`
- [x] `vite.config.ts` (externals: `@suaveplan/currency`,
`@suaveplan/currency-providers`, `@suaveplan/error`, `@suaveplan/types`,
`tslib`)
- [x] `vitest.config.ts` (istanbul, 100/100/100/100, polyfills setup)
- [x] `tests/setup/polyfills.ts`
- [x] `README.md` (≥200 words, Modules section)
## Implementation
- [x] `src/error-codes/error-codes.ts` — re-export `UNKNOWN_CURRENCY` code +
`CurrencyProviderError` from slim core for ergonomic access
- [x] `src/error-codes/error-codes.md`
- [x] `src/provider/provider.types.ts` — `VirtualCurrencyProviderConfig`,
`VirtualCurrencyProviderOptions`, `VirtualRateEntry` shapes
- [x] `src/provider/provider.ts` — `VirtualCurrencyProvider` class
- [x] `src/provider/provider.md`
- [x] `src/index.ts` — explicit named barrel
- [x] `src/provider/index.ts` (feature barrel)
## Tests
- [x] `src/provider/provider.test.ts` — covers constructor, `setRate`,
`removeRate`, `clear`, `hasRate`, `getRate`, `listCurrencies`,
`fetchRates` (default base, custom base, target filter, empty table),
`UNKNOWN_CURRENCY` error path, `FakeClock`-driven timestamp,
`supportsVirtual` flag
- [x] `tests/dep-graph.test.ts` — root barrel has no `export *`; feature
barrels have no `export *`; subpath exports match feature modules
## Documentation
- [x] Every implementation `.ts` has a sibling `.md` (§13 step 5 grep
returns zero)
- [x] `README.md` at package root with Install / Quick start / Features /
Modules sections
## Pre-archive gates (AGENTS.md §13)
- [ ] Step 0 — spec reconciliation: every `### Requirement` and
`**Verified by:**` resolves to a real file + verbatim test name
- [ ] Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-virtual`
- [ ] Step 2 — `bunx turbo test:coverage --filter=@suaveplan/currency-provider-virtual`
reports 100/100/100/100 via istanbul
- [ ] Step 3 — `bunx turbo build --filter=@suaveplan/currency-provider-virtual` green
- [ ] Step 4 — banned-pattern grep returns zero
- [ ] Step 4a — barrel-shape grep returns zero
- [ ] Step 4b — test-runner-import grep returns zero
- [ ] Step 5 — missing-`.md` grep returns zero
- [ ] Step 6 — README present
- [ ] Step 7 — bare-version grep returns zero
- [ ] Step 8 — subpath-export count matches feature module count
- [ ] Step 9 — security-scan grep returns zero
## Root-doc refresh (AGENTS.md §13b)
- [ ] Re-run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
`MANIFEST.md`
- [ ] Re-run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`
- [ ] Add `@suaveplan/currency-provider-virtual` row to `STATUS.md`
- [ ] Add `@suaveplan/currency-provider-virtual` row to `SUMMARY.md`
- [ ] `bunx markdownlint-cli2 --fix` across touched root + change files
## Archive (AGENTS.md §14)
- [ ] Fold `specs/currency-provider-virtual/spec.md` into
`openspec/specs/currency-provider-virtual/spec.md`
- [ ] Move `openspec/changes/add-currency-provider-virtual/` →
`openspec/archive/2026-05-17-add-currency-provider-virtual/`