Initial Commit
This commit is contained in:
325
openspec/specs/currency-providers/spec.md
Normal file
325
openspec/specs/currency-providers/spec.md
Normal file
@@ -0,0 +1,325 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: currency-providers
|
||||
|
||||
**Package**: `@suaveplan/currency-providers`
|
||||
**Status**: SHIPPED (2026-05-17 — `add-currency-providers`, slimmed
|
||||
2026-05-17 — `slim-currency-providers`)
|
||||
|
||||
> Folded from `openspec/changes/add-currency-providers/specs/currency-providers/spec.md` on
|
||||
> 2026-05-17 and re-folded from
|
||||
> `openspec/changes/slim-currency-providers/specs/currency-providers/spec.md`
|
||||
> on 2026-05-17 (same day; the slim refactor moves the nine concrete
|
||||
> providers out to sibling `@suaveplan/currency-provider-<name>` packages
|
||||
> and adds the registry / factory / shared-HTTP-helper core). Every
|
||||
> requirement carries its original `**Verified by:**` line citing the test
|
||||
> that pins each scenario.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`,
|
||||
`bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto`
|
||||
in any file under `src/`. The package SHALL run unchanged under Node ≥ 18,
|
||||
Bun, modern browsers, and Web Workers. `package.json` SHALL NOT declare a
|
||||
`"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL
|
||||
declare `"sideEffects": false`.
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency-providers/src/` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"src/index.ts has no export * statements"` (the same dep-graph harness enforces the structural invariants in the slim core; platform-import purity is additionally enforced by the universal-tier build target and absence of `node:*` imports in any production file)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable CURRENCY_PROVIDER_CODES error registry and CurrencyProviderError class
|
||||
|
||||
The package SHALL declare an error-code registry via
|
||||
`defineCodes("CURRENCY_PROVIDER", [...])` containing exactly the codes
|
||||
`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNSUPPORTED_BASE`,
|
||||
`MISSING_CONFIG`, `UNKNOWN_CURRENCY`, `PROVIDER_NOT_REGISTERED`, and
|
||||
`PROVIDER_ALREADY_REGISTERED`. Every public error path in this package
|
||||
and every sibling `@suaveplan/currency-provider-<name>` package SHALL
|
||||
throw a `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
|
||||
carrying one of those codes. `CurrencyProviderError` SHALL expose optional
|
||||
`providerName`, `status`, and `upstreamMessage` discriminants for
|
||||
diagnostics. The package SHALL NOT throw raw `Error` instances from any
|
||||
public method.
|
||||
|
||||
#### Scenario: registry contains every required code
|
||||
|
||||
- **WHEN** the `CURRENCY_PROVIDER_CODES` object is enumerated
|
||||
- **THEN** it contains exactly the eight namespaced codes
|
||||
(`CURRENCY_PROVIDER.HTTP_ERROR` through
|
||||
`CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED`) and no others
|
||||
|
||||
#### Scenario: registry codes are reachable from this package
|
||||
|
||||
- **WHEN** the test suite calls `registry.get("nope")` on an empty registry
|
||||
and calls `registry.register(p)` twice for the same provider name
|
||||
- **THEN** each path raises a `CurrencyProviderError` whose `code` matches
|
||||
the corresponding `CURRENCY_PROVIDER.PROVIDER_*` constant
|
||||
|
||||
#### Scenario: HTTP_ERROR is reachable from the shared httpRequest helper
|
||||
|
||||
- **WHEN** `httpRequest({ url, fetch, providerName })` is called with an
|
||||
injected fetch that returns a non-2xx Response
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
and `status` populated from the response
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `CurrencyProviderError` is thrown
|
||||
- **THEN** it is `instanceof SuaveplanError`, its `code` is a
|
||||
`CurrencyProviderErrorCode`, and its optional `providerName` / `status`
|
||||
/ `upstreamMessage` discriminants are accessible without type narrowing
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"declares every required code with namespaced value"` AND `src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"` AND `src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CurrencyProviderRegistry — name-keyed lookup of CurrencyProvider instances
|
||||
|
||||
The package SHALL export a `CurrencyProviderRegistry` class that stores
|
||||
`CurrencyProvider` instances keyed by `provider.name`. The class SHALL
|
||||
expose `register(provider: CurrencyProvider): void`,
|
||||
`get(name: string): CurrencyProvider`, `has(name: string): boolean`,
|
||||
`list(): readonly string[]`, and `unregister(name: string): boolean`.
|
||||
`get` SHALL throw `CurrencyProviderError(PROVIDER_NOT_REGISTERED)` when
|
||||
no provider has been registered under `name`. `register` SHALL throw
|
||||
`CurrencyProviderError(PROVIDER_ALREADY_REGISTERED)` when a provider with
|
||||
the same `name` is already registered. `list()` SHALL return a sorted
|
||||
snapshot (lexicographic by name) so equal sets of providers produce
|
||||
identical listings regardless of insertion order. `unregister` SHALL
|
||||
return `true` when something was removed and `false` otherwise, and SHALL
|
||||
NOT throw on unknown names.
|
||||
|
||||
#### Scenario: registers and retrieves a provider
|
||||
|
||||
- **WHEN** a provider with `name === "fake-fx"` is registered and then
|
||||
`registry.get("fake-fx")` is called
|
||||
- **THEN** the registry returns the same provider instance by identity
|
||||
(`===`) and `registry.has("fake-fx")` returns `true`
|
||||
|
||||
#### Scenario: raises PROVIDER_NOT_REGISTERED when get is called on an unknown name
|
||||
|
||||
- **WHEN** `registry.get("never-registered")` is called on a fresh registry
|
||||
- **THEN** it throws a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and whose
|
||||
`providerName === "never-registered"`
|
||||
|
||||
#### Scenario: raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name
|
||||
|
||||
- **WHEN** two distinct provider instances sharing the same `name === "dup"`
|
||||
are passed to `register` in sequence
|
||||
- **THEN** the second call throws a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and whose
|
||||
`providerName === "dup"`
|
||||
|
||||
#### Scenario: list returns a sorted snapshot
|
||||
|
||||
- **WHEN** providers are registered in the order `"zeta"`, `"alpha"`, `"mu"`
|
||||
- **THEN** `registry.list()` returns `["alpha", "mu", "zeta"]` (lexicographic)
|
||||
|
||||
#### Scenario: unregister removes and reports
|
||||
|
||||
- **WHEN** `registry.unregister("alpha")` is called after `"alpha"` was
|
||||
registered, followed by a second `registry.unregister("alpha")`
|
||||
- **THEN** the first call returns `true`, the second returns `false`,
|
||||
`registry.has("alpha")` returns `false`, and a subsequent
|
||||
`registry.get("alpha")` throws `PROVIDER_NOT_REGISTERED`
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"registers and retrieves a provider"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"` AND `src/registry/registry.test.ts::"list returns a sorted snapshot"` AND `src/registry/registry.test.ts::"unregister removes and reports correctly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: defineCurrencyProvider — factory for object-literal providers
|
||||
|
||||
The package SHALL export `defineCurrencyProvider(input)` — a factory that
|
||||
returns an `Object.freeze`-d implementation of the `CurrencyProvider`
|
||||
contract from `@suaveplan/currency`. The factory input SHALL be a named
|
||||
interface (`DefineCurrencyProviderInput`) requiring `name: string`,
|
||||
`supportsCrypto: boolean`, `supportsFiat: boolean`, and
|
||||
`fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>`,
|
||||
plus an optional `supportsVirtual?: boolean`. The returned provider SHALL
|
||||
forward every `fetchRates` call to the supplied function unchanged and
|
||||
SHALL preserve every optional flag.
|
||||
|
||||
#### Scenario: factory returns a frozen CurrencyProvider
|
||||
|
||||
- **WHEN** `defineCurrencyProvider({ name: "fake", supportsCrypto: false,
|
||||
supportsFiat: true, fetchRates: async () => ({ base: "USD", rates: {},
|
||||
timestamp: 0 }) })` is called
|
||||
- **THEN** the returned object is `Object.frozen`, satisfies the
|
||||
`CurrencyProvider` type, and exposes `name === "fake"`,
|
||||
`supportsCrypto === false`, `supportsFiat === true`
|
||||
|
||||
#### Scenario: factory forwards fetchRates calls
|
||||
|
||||
- **WHEN** a provider built by the factory has its `fetchRates(options)`
|
||||
called with `options === { base: "EUR" }`
|
||||
- **THEN** the underlying user-supplied function receives the same
|
||||
`options` and the promise it returns is the value returned by the
|
||||
provider
|
||||
|
||||
#### Scenario: factory preserves supportsVirtual
|
||||
|
||||
- **WHEN** the factory is called with `supportsVirtual: true`
|
||||
- **THEN** the returned provider exposes `supportsVirtual === true`; when
|
||||
the flag is omitted the property is absent
|
||||
|
||||
**Verified by:** `src/factory/factory.test.ts::"returns a frozen CurrencyProvider"` AND `src/factory/factory.test.ts::"forwards fetchRates calls to the supplied function"` AND `src/factory/factory.test.ts::"preserves supportsVirtual when provided"` AND `src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: httpRequest + composeAbortSignal — shared HTTP utilities for sibling providers
|
||||
|
||||
The package SHALL export `httpRequest(input)` — an async helper that
|
||||
performs a request through an injected `FetchLike`, applies an optional
|
||||
`timeoutMs` via `AbortSignal.timeout`, composes the caller's optional
|
||||
`signal` with the timeout signal via `composeAbortSignal`, attaches the
|
||||
combined signal and any `init` to the request, throws
|
||||
`CurrencyProviderError(HTTP_ERROR)` (with `providerName` and `status`
|
||||
populated) on a non-2xx response, and returns the raw `Response`
|
||||
otherwise. The package SHALL also export `composeAbortSignal(caller,
|
||||
timeout)` — a pure helper that returns `undefined` when both inputs are
|
||||
`undefined`, returns the single input when only one is provided, and
|
||||
returns a combined signal that aborts when either input aborts when both
|
||||
are provided.
|
||||
|
||||
#### Scenario: returns the response on a 2xx
|
||||
|
||||
- **WHEN** `httpRequest({ url: "https://x/y", fetch: ok, providerName: "fake" })`
|
||||
is called against a recording fetch that returns `new Response("ok",
|
||||
{ status: 200 })`
|
||||
- **THEN** the helper resolves to that Response, and the recorded URL and
|
||||
init exactly match the inputs
|
||||
|
||||
#### Scenario: raises HTTP_ERROR on non-2xx response
|
||||
|
||||
- **WHEN** the injected fetch returns a `Response` with `status: 503`
|
||||
- **THEN** the helper rejects with `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and
|
||||
`providerName === "fake"`
|
||||
|
||||
#### Scenario: forwards init.headers to the injected fetch
|
||||
|
||||
- **WHEN** `httpRequest({ ..., init: { headers: { "x-test": "1" } } })` is
|
||||
called against a recording fetch
|
||||
- **THEN** the recorded `init.headers` contains `"x-test" === "1"`
|
||||
|
||||
#### Scenario: aborts when the caller signal aborts
|
||||
|
||||
- **WHEN** `httpRequest({ ..., signal })` is called and the caller aborts
|
||||
`signal` before the fetch resolves
|
||||
- **THEN** the helper rejects with the abort reason (a `DOMException` or
|
||||
the signal's `reason`)
|
||||
|
||||
#### Scenario: aborts when the timeout signal fires
|
||||
|
||||
- **WHEN** `httpRequest({ ..., timeoutMs: 1 })` is called against a fetch
|
||||
that never resolves within the timeout
|
||||
- **THEN** the helper rejects with a timeout abort
|
||||
|
||||
#### Scenario: composeAbortSignal returns undefined when both inputs are undefined
|
||||
|
||||
- **WHEN** `composeAbortSignal(undefined, undefined)` is called
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
#### Scenario: composeAbortSignal returns the single input when only one is provided
|
||||
|
||||
- **WHEN** `composeAbortSignal(callerOnly, undefined)` or
|
||||
`composeAbortSignal(undefined, timeoutOnly)` is called
|
||||
- **THEN** the result is identically `callerOnly` or `timeoutOnly`
|
||||
respectively
|
||||
|
||||
#### Scenario: composeAbortSignal aborts when either input aborts
|
||||
|
||||
- **WHEN** both a caller `AbortController` and a timeout
|
||||
`AbortController` are passed and either one fires
|
||||
- **THEN** the composed signal's `aborted` flag becomes `true`
|
||||
|
||||
**Verified by:** `src/http/http.test.ts::"returns the response on a 2xx"` AND `src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/http/http.test.ts::"forwards init.headers to the injected fetch"` AND `src/http/http.test.ts::"aborts when the caller signal aborts"` AND `src/http/http.test.ts::"aborts when the timeout signal fires"` AND `src/http/http.test.ts::"composeAbortSignal returns undefined when both inputs are undefined"` AND `src/http/http.test.ts::"composeAbortSignal returns the single input when only one is provided"` AND `src/http/http.test.ts::"composeAbortSignal aborts when either input aborts"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED codes added to the registry
|
||||
|
||||
The `CURRENCY_PROVIDER_CODES` registry SHALL include
|
||||
`PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` so the
|
||||
`CurrencyProviderRegistry` can dispatch on typed codes (no plain `Error`).
|
||||
Both codes SHALL be reachable from `src/` call sites in this package's
|
||||
test suite.
|
||||
|
||||
#### Scenario: both new codes are present and namespaced
|
||||
|
||||
- **WHEN** `CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED` and
|
||||
`CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED` are read
|
||||
- **THEN** the values are `"CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"`
|
||||
and `"CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` respectively
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"declares every required code with namespaced value"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configuration types are named interfaces
|
||||
|
||||
The package SHALL export `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`,
|
||||
and `ProviderConfigBase` as named TypeScript interfaces (no inline object
|
||||
literals in constructor or factory signatures). Sibling
|
||||
`@suaveplan/currency-provider-<name>` packages SHALL type their API-keyed
|
||||
configs as `ApiKeyConfig`, their no-key configs as `NoKeyConfig`, and
|
||||
their HTTP-call options as `HttpFetchOptions`. Every provider config SHALL
|
||||
extend with an optional `fetch?: FetchLike` (carried on `ProviderConfigBase`).
|
||||
|
||||
#### Scenario: configs are exported from the slim core
|
||||
|
||||
- **WHEN** a consumer imports `ApiKeyConfig`, `NoKeyConfig`,
|
||||
`HttpFetchOptions`, and `ProviderConfigBase` from
|
||||
`@suaveplan/currency-providers`
|
||||
- **THEN** all four types are accepted by TypeScript and `ApiKeyConfig`
|
||||
enforces a required `apiKey: string`
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"accepts a defineCurrencyProvider-produced provider via ApiKeyConfig contract"` AND `src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Explicit named barrel exports
|
||||
|
||||
The package SHALL re-export every public symbol from `src/index.ts` using
|
||||
explicit named re-exports (`export { Name } from "..."`) — no `export *`
|
||||
anywhere except namespace re-exports (`export * as <ns> from`). Every
|
||||
feature module (`<feature>/index.ts`) SHALL likewise use explicit named
|
||||
re-exports.
|
||||
|
||||
#### Scenario: src/index.ts has no `export *`
|
||||
|
||||
- **WHEN** `grep -nE "^\s*export\s+(type\s+)?\*\s+from" src/index.ts` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"src/index.ts has no export * statements"` AND `tests/dep-graph.test.ts::"feature barrels have no export * statements"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per public module
|
||||
|
||||
`package.json` SHALL declare a subpath export for every public module so
|
||||
consumers can tree-shake. The slim core SHALL declare exactly five
|
||||
subpaths beyond `./package.json` and the root `.`:
|
||||
`./error-codes/error-codes`, `./types/configs`, `./registry/registry`,
|
||||
`./factory/factory`, `./http/http`. No `./providers/*` subpaths SHALL be
|
||||
declared — provider implementations live in sibling packages.
|
||||
|
||||
#### Scenario: every feature module has a subpath export
|
||||
|
||||
- **WHEN** `package.json` `exports` map is enumerated
|
||||
- **THEN** it contains exactly six entries (root `.` + the five listed
|
||||
subpaths), each pointing to the matching compiled `.js` and `.d.ts`, and
|
||||
it contains zero entries starting with `./providers/`
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"package.json declares the five slim-core subpath exports and no provider subpaths"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user