From da251c774317a194e1ff8c33e37fbb627272cb6d Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:07:38 +0100 Subject: [PATCH] feat(core): bootstrap @payenv/core with the safe-fallback attempt engine - TypeScript monorepo (pnpm workspaces, TypeScript 7, Vitest, Biome) - Money model in integer minor units with ISO 4217 exponents - Payment methods, unified statuses and transitions - Error taxonomy with retry classes (safe_to_fallback, do_not_retry, ambiguous) - Connector and capability interfaces - Capability-aware router with the priority strategy - Attempt engine enforcing ADR 0004: never try another connector while a previous attempt could still succeed; ambiguous attempts are resolved by checking the status with the same provider - Idempotency (atomic store create), refresh, lifecycle events - In-memory store for development and tests - CI on Node 20/22/24 with a DCO sign-off check The randomized invariant test tracks independently whether each provider proved that no money moved, and was checked against injected double-charge bugs. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .github/workflows/ci.yml | 51 ++ CHANGELOG.md | 6 + CONTRIBUTING.md | 22 +- README.fr.md | 2 +- README.md | 2 +- biome.json | 37 + docs/ARCHITECTURE.md | 12 +- docs/ROADMAP.md | 11 +- package.json | 25 + packages/core/README.md | 33 + packages/core/package.json | 42 + packages/core/src/connector.ts | 104 +++ packages/core/src/errors.ts | 100 +++ packages/core/src/index.ts | 44 + packages/core/src/method.ts | 53 ++ packages/core/src/money.ts | 93 +++ packages/core/src/payenv.ts | 479 +++++++++++ packages/core/src/payment.ts | 50 ++ packages/core/src/router.ts | 45 + packages/core/src/status.ts | 47 ++ packages/core/src/store.ts | 34 + packages/core/test/fallback.test.ts | 268 ++++++ packages/core/test/helpers.ts | 69 ++ packages/core/test/model.test.ts | 61 ++ packages/core/test/payenv.test.ts | 158 ++++ packages/core/tsconfig.build.json | 8 + packages/core/tsconfig.json | 4 + pnpm-lock.yaml | 1191 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + tsconfig.base.json | 19 + vitest.config.ts | 7 + 31 files changed, 3065 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 biome.json create mode 100644 package.json create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/connector.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/method.ts create mode 100644 packages/core/src/money.ts create mode 100644 packages/core/src/payenv.ts create mode 100644 packages/core/src/payment.ts create mode 100644 packages/core/src/router.ts create mode 100644 packages/core/src/status.ts create mode 100644 packages/core/src/store.ts create mode 100644 packages/core/test/fallback.test.ts create mode 100644 packages/core/test/helpers.ts create mode 100644 packages/core/test/model.test.ts create mode 100644 packages/core/test/payenv.test.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..babddc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + name: Lint, typecheck, test, build (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + + dco: + name: DCO sign-off + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check that every commit is signed off + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + missing=0 + for sha in $(git rev-list --no-merges "$BASE..$HEAD"); do + if ! git log -1 --format=%B "$sha" | grep -q '^Signed-off-by: '; then + echo "::error::Commit $sha is missing a Signed-off-by line (use git commit -s)" + missing=1 + fi + done + exit $missing diff --git a/CHANGELOG.md b/CHANGELOG.md index 516096a..ffec1e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Before `1.0.0`, minor versions may contain breaking changes. ## [Unreleased] ### Added +- 2026-09-26 — `@payenv/core`: money model (integer minor units, ISO 4217 exponents), + payment methods, unified statuses, error taxonomy with retry classes, connector and + capability interfaces, capability-aware router with the `priority` strategy, the + attempt engine enforcing the safe-fallback invariant (ADR 0004), idempotency, + `refresh`, lifecycle events, and an in-memory store. +- 2026-09-26 — TypeScript monorepo (pnpm, Vitest, Biome) and CI with DCO check. - 2026-09-26 — Project started. Vision, architecture draft, roadmap, Apache 2.0 license, contributing guide, code of conduct, security policy, governance, and initial architecture decision records. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c97f6e..bff2160 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,5 +57,23 @@ license explicitly allows it. ## Development setup -*Coming with Phase 1 (see [ROADMAP.md](docs/ROADMAP.md)).* The planned stack is -TypeScript, with a monorepo and a zero-dependency core built on `fetch`. +Requirements: Node.js 20.19+ and [pnpm](https://pnpm.io) 9. + +```sh +pnpm install +pnpm check # lint + typecheck + tests +pnpm test:watch # tests in watch mode +pnpm format # auto-format with Biome +pnpm build # compile every package to dist/ +``` + +Layout: + +``` +packages/ + core/ @payenv/core — model, router, attempt engine (zero runtime dependencies) + src/ + test/ +``` + +The stack is TypeScript 7 (`tsc`), Vitest for tests, and Biome for lint and format. diff --git a/README.fr.md b/README.fr.md index 74eed54..a1f581a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ 🇬🇧 [Read in English](README.md) — *la version anglaise fait référence.* -**Statut :** 🌱 Conception — aucune version utilisable pour l'instant. Démarré le **26/09/2026**. +**Statut :** 🌱 Début du développement — le moteur existe, pas encore de connecteur ni de version publiée. Démarré le **26/09/2026**. **Licence :** [Apache 2.0](LICENSE) — gratuit pour un usage personnel et commercial, pour toujours. --- diff --git a/README.md b/README.md index ef3a691..92f7c5c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 🇫🇷 [Lire en français](README.fr.md) -**Status:** 🌱 Early design — no usable release yet. Started on **2026-09-26**. +**Status:** 🌱 Early development — the core engine exists, no connector or release yet. Started on **2026-09-26**. **License:** [Apache 2.0](LICENSE) — free for personal and commercial use, forever. --- diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..bb487c7 --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/dist", "!**/*.md"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4706d78..8a54f13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -180,7 +180,9 @@ Idempotency: Payenv needs a small amount of state (payments, attempts, idempotency keys, processed webhook IDs, success-rate stats). The store is an interface with -adapters: `memory` (dev/tests), then `postgres`, `redis`, `sqlite`, … +adapters: `memory` (dev/tests, built into the core), then `postgres`, `redis`, +`sqlite`, … Its `create` operation must be atomic, so that two concurrent calls +with the same idempotency key can never both start a charge. The integrator owns the store and its data. ## 7. Observability @@ -189,14 +191,14 @@ The integrator owns the store and its data. wire to its own logs, metrics, or tracing (OpenTelemetry-friendly). - No built-in telemetry sent anywhere. -## 8. Packaging (initial proposal) +## 8. Packaging ``` -@payenv/core unified model, router, attempt engine, interfaces +@payenv/core unified model, router, attempt engine, interfaces, + and an in-memory store for development and tests @payenv/connector-fedapay one package per connector @payenv/connector-kkiapay -@payenv/store-memory -@payenv/store-postgres +@payenv/store-postgres persistent stores, one package each ``` ## Open questions diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ef45d0f..7f9edae 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,11 +13,12 @@ ## Phase 1 — Core + first connector (`v0.1.0`) -- [ ] TypeScript monorepo, CI, lint, tests, release automation -- [ ] `@payenv/core`: unified model, error taxonomy, statuses -- [ ] Router with the `priority` strategy + capability filtering -- [ ] Attempt engine with the safe-fallback invariant, fully tested -- [ ] `@payenv/store-memory` +- [x] TypeScript monorepo, CI, lint, tests +- [ ] Release automation +- [x] `@payenv/core`: unified model, error taxonomy, statuses +- [x] Router with the `priority` strategy + capability filtering +- [x] Attempt engine with the safe-fallback invariant, fully tested +- [x] In-memory store (built into `@payenv/core`) - [ ] `@payenv/connector-fedapay` (collect mobile money, status, webhooks) - [ ] Getting-started guide and one runnable example diff --git a/package.json b/package.json new file mode 100644 index 0000000..de37b75 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "payenv-monorepo", + "private": true, + "description": "Payenv — one payment environment, many aggregators.", + "license": "Apache-2.0", + "type": "module", + "packageManager": "pnpm@9.15.9", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check .", + "format": "biome check --write .", + "check": "pnpm lint && pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.14", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } +} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..5a1de02 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,33 @@ +# @payenv/core + +The core of [Payenv](https://github.com/payenv-dev/payenv): the unified payment model, +the router, and the safe-fallback attempt engine. + +> 🚧 Pre-release (`0.x`). The API may change. + +```ts +import { createPayenv } from '@payenv/core'; + +const payenv = createPayenv({ + connectors: [/* connectors, e.g. @payenv/connector-fedapay */], + // store: a persistent store in production (the default is in-memory) +}); + +const payment = await payenv.collect({ + amount: { value: 5000, currency: 'XOF' }, + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + idempotencyKey: 'order_1234', +}); + +// payment.status: 'pending' | 'requires_action' | 'succeeded' | 'failed' | 'unknown' | ... +// A payment in 'unknown' must be reconciled later with payenv.refresh('order_1234'). +``` + +**Guarantee:** Payenv never starts an attempt on another connector while a previous +attempt for the same payment could still succeed. See +[ADR 0004](https://github.com/payenv-dev/payenv/blob/main/docs/adr/0004-no-fallback-on-ambiguous-state.md). + +Requires Node.js 20.19+ (ESM; `require()` works on Node 20.19+ as well), Deno, Bun, or +any runtime with `fetch`, `AbortController`, and `crypto.randomUUID`. + +License: Apache-2.0 diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..073f6d7 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,42 @@ +{ + "name": "@payenv/core", + "version": "0.0.0", + "description": "Payenv core: unified payment model, router, and safe-fallback attempt engine.", + "license": "Apache-2.0", + "author": "Jules Mahounou (https://github.com/jules-mahounou)", + "homepage": "https://github.com/payenv-dev/payenv#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/payenv-dev/payenv.git", + "directory": "packages/core" + }, + "bugs": "https://github.com/payenv-dev/payenv/issues", + "keywords": [ + "payments", + "payment-orchestration", + "mobile-money", + "fallback" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=20.19" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts new file mode 100644 index 0000000..4475b2d --- /dev/null +++ b/packages/core/src/connector.ts @@ -0,0 +1,104 @@ +import type { PayenvError } from './errors.js'; +import type { + CountryCode, + MobileMoneyNetwork, + PaymentMethod, + PaymentMethodType, +} from './method.js'; +import type { CurrencyCode, Money } from './money.js'; +import type { PaymentStatus } from './status.js'; + +export type Operation = 'collect'; + +export interface Customer { + firstName?: string; + lastName?: string; + email?: string; + /** E.164 phone number. */ + phone?: string; +} + +export interface CollectRequest { + amount: Money; + method: PaymentMethod; + customer?: Customer; + description?: string; + /** + * Unique key chosen by the application (e.g. its order id). The same key always + * returns the same payment and never creates a second charge. + */ + idempotencyKey: string; + metadata?: Readonly>; +} + +/** What a connector declares it can serve. Omitted lists mean "any". */ +export interface Capability { + operation: Operation; + method: PaymentMethodType; + currencies: readonly CurrencyCode[]; + countries?: readonly CountryCode[]; + /** Only meaningful for `mobile_money`. */ + networks?: readonly MobileMoneyNetwork[]; +} + +/** An instruction the application must follow to complete the payment. */ +export type NextAction = + | { type: 'redirect'; url: string } + | { type: 'customer_confirmation'; channel?: 'ussd' | 'sms' | 'app'; message?: string }; + +/** Result of a provider call, normalized by the connector. */ +export interface ProviderResult { + status: PaymentStatus; + /** The provider's identifier for this transaction. */ + providerRef?: string; + nextAction?: NextAction; + /** Required when `status` is `failed`, `canceled` or `expired`, to classify the failure. */ + error?: PayenvError; + raw?: unknown; +} + +/** + * Result of a status lookup. + * + * `found: false` must only be returned when the provider **authoritatively** + * confirms it has no record of the transaction (not on a transient error), because + * Payenv will then consider it safe to try another connector. + */ +export type StatusResult = ({ found: true } & ProviderResult) | { found: false; raw?: unknown }; + +export interface AttemptContext { + paymentId: string; + attemptId: string; + /** + * Unique reference of this attempt. Connectors should send it to the provider as + * the merchant reference, so the transaction can be found even if the call timed out. + */ + reference: string; + /** Idempotency key to forward to providers that support one (unique per attempt). */ + idempotencyKey: string; + /** Aborted when the attempt times out. Pass it to `fetch`. */ + signal: AbortSignal; +} + +export interface StatusQuery { + operation: Operation; + reference: string; + providerRef?: string; +} + +export interface StatusContext { + signal: AbortSignal; +} + +/** An adapter for one payment aggregator. The only place with provider-specific code. */ +export interface Connector { + readonly id: string; + capabilities(): readonly Capability[]; + collect(request: CollectRequest, context: AttemptContext): Promise; + getStatus(query: StatusQuery, context: StatusContext): Promise; + /** + * Maps an error thrown by `collect` / `getStatus` to a {@link PayenvError}. + * Errors that are not mapped are treated as `ambiguous`. + */ + mapError?(error: unknown): PayenvError; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..4a1718e --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,100 @@ +/** + * How the attempt engine may react to an error. + * + * - `safe_to_fallback` — the provider rejected the request before any money moved; + * another connector may be tried. + * - `do_not_retry` — the outcome would be the same with another provider + * (customer declined, insufficient funds, invalid request…). + * - `ambiguous` — the provider may have processed the request. Payenv must check + * the status with the same provider before doing anything else. + */ +export type RetryClass = 'safe_to_fallback' | 'do_not_retry' | 'ambiguous'; + +export type PayenvErrorCode = + // safe_to_fallback + | 'PROVIDER_UNAVAILABLE' + | 'RATE_LIMITED' + | 'ROUTE_UNSUPPORTED' + | 'AUTHENTICATION_FAILED' + // do_not_retry + | 'INSUFFICIENT_FUNDS' + | 'CUSTOMER_DECLINED' + | 'CUSTOMER_TIMEOUT' + | 'INVALID_PHONE' + | 'LIMIT_EXCEEDED' + | 'FRAUD_SUSPECTED' + | 'INVALID_REQUEST' + | 'NO_ROUTE' + | 'IDEMPOTENCY_CONFLICT' + | 'PAYMENT_NOT_FOUND' + // ambiguous + | 'TIMEOUT' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + +export const DEFAULT_RETRY_CLASS: Readonly> = { + PROVIDER_UNAVAILABLE: 'safe_to_fallback', + RATE_LIMITED: 'safe_to_fallback', + ROUTE_UNSUPPORTED: 'safe_to_fallback', + AUTHENTICATION_FAILED: 'safe_to_fallback', + INSUFFICIENT_FUNDS: 'do_not_retry', + CUSTOMER_DECLINED: 'do_not_retry', + CUSTOMER_TIMEOUT: 'do_not_retry', + INVALID_PHONE: 'do_not_retry', + LIMIT_EXCEEDED: 'do_not_retry', + FRAUD_SUSPECTED: 'do_not_retry', + INVALID_REQUEST: 'do_not_retry', + NO_ROUTE: 'do_not_retry', + IDEMPOTENCY_CONFLICT: 'do_not_retry', + PAYMENT_NOT_FOUND: 'do_not_retry', + TIMEOUT: 'ambiguous', + NETWORK_ERROR: 'ambiguous', + UNKNOWN_ERROR: 'ambiguous', +}; + +export interface PayenvErrorOptions { + /** Overrides the default retry class of the code. */ + retryClass?: RetryClass; + /** Connector that produced the error, if any. */ + connectorId?: string; + /** Original provider response or error, kept for debugging. Never shown to end users. */ + raw?: unknown; + cause?: unknown; +} + +export class PayenvError extends Error { + override readonly name = 'PayenvError'; + readonly code: PayenvErrorCode; + readonly retryClass: RetryClass; + readonly connectorId: string | undefined; + readonly raw: unknown; + + constructor(code: PayenvErrorCode, message: string, options: PayenvErrorOptions = {}) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.code = code; + this.retryClass = options.retryClass ?? DEFAULT_RETRY_CLASS[code]; + this.connectorId = options.connectorId; + this.raw = options.raw; + } + + /** A plain, serializable representation (without `raw`). */ + toJSON(): SerializedError { + return { + code: this.code, + message: this.message, + retryClass: this.retryClass, + ...(this.connectorId === undefined ? {} : { connectorId: this.connectorId }), + }; + } +} + +export interface SerializedError { + code: PayenvErrorCode; + message: string; + retryClass: RetryClass; + connectorId?: string; +} + +export function isPayenvError(value: unknown): value is PayenvError { + return value instanceof PayenvError; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..6b8a0e8 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,44 @@ +export type { + AttemptContext, + Capability, + CollectRequest, + Connector, + Customer, + NextAction, + Operation, + ProviderResult, + StatusContext, + StatusQuery, + StatusResult, +} from './connector.js'; +export { + DEFAULT_RETRY_CLASS, + isPayenvError, + PayenvError, + type PayenvErrorCode, + type PayenvErrorOptions, + type RetryClass, + type SerializedError, +} from './errors.js'; +export type { + CardMethod, + CountryCode, + HostedPageMethod, + MobileMoneyMethod, + MobileMoneyNetwork, + PaymentMethod, + PaymentMethodType, +} from './method.js'; +export { + assertValidMoney, + type CurrencyCode, + currencyExponent, + fromMajor, + type Money, + money, +} from './money.js'; +export { createPayenv, type Payenv, type PayenvEvent, type PayenvOptions } from './payenv.js'; +export type { Attempt, AttemptOutcome, Payment } from './payment.js'; +export { priority, type RoutingStrategy, supports } from './router.js'; +export { canTransition, isTerminal, type PaymentStatus, type TerminalStatus } from './status.js'; +export { createMemoryStore, type Store } from './store.js'; diff --git a/packages/core/src/method.ts b/packages/core/src/method.ts new file mode 100644 index 0000000..8100687 --- /dev/null +++ b/packages/core/src/method.ts @@ -0,0 +1,53 @@ +/** ISO 3166-1 alpha-2 country code, e.g. `BJ`, `CI`, `SN`. */ +export type CountryCode = string; + +/** + * Payenv's own, stable mobile money network identifiers. Connectors map them to + * their provider's slugs (e.g. FedaPay uses `mtn_open` for MTN collections). + * Other identifiers are accepted, so new networks don't require a core release. + */ +export type MobileMoneyNetwork = + | 'mtn' + | 'moov' + | 'orange' + | 'wave' + | 'celtiis' + | 'free' + | 'airtel' + | 'mpesa' + | 'togocom' + | (string & {}); + +export interface MobileMoneyMethod { + type: 'mobile_money'; + network: MobileMoneyNetwork; + country: CountryCode; + /** E.164 phone number, e.g. `+22990000000`. */ + phone: string; +} + +export interface CardMethod { + type: 'card'; + /** Token from the provider's hosted fields. Payenv never handles raw card numbers. */ + token: string; + country?: CountryCode; +} + +export interface HostedPageMethod { + type: 'hosted_page'; + /** Where the provider sends the customer back after checkout. */ + returnUrl: string; + country?: CountryCode; +} + +export type PaymentMethod = MobileMoneyMethod | CardMethod | HostedPageMethod; + +export type PaymentMethodType = PaymentMethod['type']; + +export function methodCountry(method: PaymentMethod): CountryCode | undefined { + return method.country; +} + +export function methodNetwork(method: PaymentMethod): MobileMoneyNetwork | undefined { + return method.type === 'mobile_money' ? method.network : undefined; +} diff --git a/packages/core/src/money.ts b/packages/core/src/money.ts new file mode 100644 index 0000000..78a3d9c --- /dev/null +++ b/packages/core/src/money.ts @@ -0,0 +1,93 @@ +import { PayenvError } from './errors.js'; + +/** ISO 4217 alphabetic currency code, e.g. `XOF`, `EUR`, `NGN`. */ +export type CurrencyCode = string; + +/** + * An amount of money, always an integer in the currency's minor unit. + * + * - `{ value: 5000, currency: 'XOF' }` → 5000 FCFA (XOF has no minor unit) + * - `{ value: 1250, currency: 'EUR' }` → €12.50 + */ +export interface Money { + readonly value: number; + readonly currency: CurrencyCode; +} + +/** ISO 4217 currencies whose minor unit exponent is 0. */ +const ZERO_DECIMAL = new Set([ + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'ISK', + 'JPY', + 'KMF', + 'KRW', + 'PYG', + 'RWF', + 'UGX', + 'UYI', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', +]); + +/** ISO 4217 currencies whose minor unit exponent is 3. */ +const THREE_DECIMAL = new Set(['BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND']); + +const CURRENCY_FORMAT = /^[A-Z]{3}$/; + +/** Number of decimal digits of the currency's minor unit (ISO 4217 exponent). */ +export function currencyExponent(currency: CurrencyCode): number { + if (ZERO_DECIMAL.has(currency)) return 0; + if (THREE_DECIMAL.has(currency)) return 3; + return 2; +} + +/** Validates and returns a {@link Money} value. Throws `INVALID_REQUEST` if invalid. */ +export function money(value: number, currency: CurrencyCode): Money { + assertValidMoney({ value, currency }); + return { value, currency }; +} + +export function assertValidMoney(amount: Money): void { + if (!CURRENCY_FORMAT.test(amount.currency)) { + throw new PayenvError( + 'INVALID_REQUEST', + `Invalid currency code "${amount.currency}" (expected ISO 4217, e.g. "XOF")`, + ); + } + if (!Number.isSafeInteger(amount.value)) { + throw new PayenvError( + 'INVALID_REQUEST', + `Amount must be an integer in the currency's minor unit, got ${amount.value}`, + ); + } + if (amount.value <= 0) { + throw new PayenvError('INVALID_REQUEST', `Amount must be positive, got ${amount.value}`); + } +} + +/** + * Converts a major-unit decimal string (e.g. `"12.50"`) to minor units without + * floating-point arithmetic. + */ +export function fromMajor(major: string, currency: CurrencyCode): Money { + const exponent = currencyExponent(currency); + const match = /^(\d+)(?:\.(\d+))?$/.exec(major.trim()); + if (!match) { + throw new PayenvError('INVALID_REQUEST', `Invalid amount "${major}"`); + } + const [, whole = '0', fraction = ''] = match; + if (fraction.length > exponent) { + throw new PayenvError( + 'INVALID_REQUEST', + `${currency} allows at most ${exponent} decimal(s), got "${major}"`, + ); + } + const value = Number(whole + fraction.padEnd(exponent, '0')); + return money(value, currency); +} diff --git a/packages/core/src/payenv.ts b/packages/core/src/payenv.ts new file mode 100644 index 0000000..3c96a4b --- /dev/null +++ b/packages/core/src/payenv.ts @@ -0,0 +1,479 @@ +import type { + CollectRequest, + Connector, + ProviderResult, + StatusQuery, + StatusResult, +} from './connector.js'; +import { isPayenvError, PayenvError, type SerializedError } from './errors.js'; +import { assertValidMoney } from './money.js'; +import type { Attempt, Payment } from './payment.js'; +import { priority, type RoutingStrategy, supports } from './router.js'; +import { canTransition, isTerminal, type PaymentStatus } from './status.js'; +import { createMemoryStore, type Store } from './store.js'; + +export type PayenvEvent = + | { type: 'attempt.started'; payment: Payment; attempt: Attempt } + | { type: 'attempt.finished'; payment: Payment; attempt: Attempt } + | { type: 'fallback'; payment: Payment; from: string; to: string; reason: SerializedError } + | { type: 'payment.updated'; payment: Payment }; + +export interface PayenvOptions { + connectors: readonly Connector[]; + /** Defaults to {@link priority} with registration order. */ + routing?: RoutingStrategy; + /** + * Defaults to an in-memory store, which is only suitable for development: + * idempotency is lost on restart and not shared between instances. + */ + store?: Store; + /** Maximum duration of a single provider call. Defaults to 30 seconds. */ + attemptTimeoutMs?: number; + /** + * Delays before each status check when an attempt is ambiguous. + * Defaults to `[1000, 3000, 5000]` (three checks). + */ + statusCheckDelaysMs?: readonly number[]; + /** Called for every lifecycle event. Errors thrown by the handler are ignored. */ + onEvent?: (event: PayenvEvent) => void; +} + +export interface Payenv { + /** Collects a payment, falling back between connectors only when it is proven safe. */ + collect(request: CollectRequest): Promise; + getPayment(idempotencyKey: string): Promise; + /** Asks the provider for the latest status of a non-terminal payment. */ + refresh(idempotencyKey: string): Promise; +} + +const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; +const DEFAULT_STATUS_CHECK_DELAYS_MS = [1_000, 3_000, 5_000]; +const E164 = /^\+[1-9]\d{6,14}$/; + +/** How the engine concludes an attempt. Only `fallback` lets the next connector run. */ +type Decision = + | { kind: 'accept'; result: ProviderResult } + | { kind: 'fallback'; error: PayenvError } + | { kind: 'fail'; status: PaymentStatus; error: PayenvError } + | { kind: 'unresolved'; error: PayenvError }; + +type Classification = Decision | { kind: 'ambiguous'; error: PayenvError }; + +export function createPayenv(options: PayenvOptions): Payenv { + const connectors = [...options.connectors]; + if (connectors.length === 0) { + throw new PayenvError('INVALID_REQUEST', 'At least one connector is required'); + } + const ids = new Set(); + for (const connector of connectors) { + if (ids.has(connector.id)) { + throw new PayenvError('INVALID_REQUEST', `Duplicate connector id "${connector.id}"`); + } + ids.add(connector.id); + } + + const routing = options.routing ?? priority(); + const store = options.store ?? createMemoryStore(); + const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const statusCheckDelaysMs = options.statusCheckDelaysMs ?? DEFAULT_STATUS_CHECK_DELAYS_MS; + + function emit(event: PayenvEvent): void { + if (!options.onEvent) return; + try { + options.onEvent(structuredClone(event)); + } catch { + // A faulty event handler must never break a payment. + } + } + + async function save(payment: Payment): Promise { + payment.updatedAt = now(); + await store.update(payment); + emit({ type: 'payment.updated', payment }); + } + + async function checkStatus(connector: Connector, query: StatusQuery): Promise { + const controller = new AbortController(); + return withTimeout( + connector.getStatus(query, { signal: controller.signal }), + attemptTimeoutMs, + controller, + ); + } + + /** + * Called when we don't know whether the provider processed the attempt. + * Only the same provider can tell us; we never move on while it could still succeed. + */ + async function resolveAmbiguous( + connector: Connector, + query: StatusQuery, + cause: PayenvError, + ): Promise { + for (const delay of statusCheckDelaysMs) { + await sleep(delay); + let status: StatusResult; + try { + status = await checkStatus(connector, query); + } catch { + continue; + } + if (!status.found) return { kind: 'fallback', error: cause }; + const classification = classify(status, connector); + if (classification.kind !== 'ambiguous') return classification; + } + return { kind: 'unresolved', error: cause }; + } + + async function runAttempt( + connector: Connector, + request: CollectRequest, + payment: Payment, + attempt: Attempt, + ): Promise { + const controller = new AbortController(); + const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + let classification: Classification; + try { + const result = await withTimeout( + connector.collect(request, { + paymentId: payment.id, + attemptId: attempt.id, + reference: attempt.reference, + idempotencyKey: attempt.id, + signal: controller.signal, + }), + attemptTimeoutMs, + controller, + ); + if (result.providerRef !== undefined) { + attempt.providerRef = result.providerRef; + query.providerRef = result.providerRef; + } + classification = classify(result, connector); + } catch (thrown) { + const error = toPayenvError(connector, thrown); + classification = + error.retryClass === 'safe_to_fallback' + ? { kind: 'fallback', error } + : error.retryClass === 'do_not_retry' + ? { kind: 'fail', status: 'failed', error } + : { kind: 'ambiguous', error }; + } + if (classification.kind !== 'ambiguous') return classification; + return resolveAmbiguous(connector, query, classification.error); + } + + function apply(payment: Payment, attempt: Attempt, decision: Decision): void { + attempt.endedAt = now(); + switch (decision.kind) { + case 'accept': { + const { result } = decision; + const status = result.status === 'created' ? 'pending' : result.status; + attempt.status = status; + attempt.outcome = 'accepted'; + if (result.providerRef !== undefined) attempt.providerRef = result.providerRef; + payment.status = status; + setOptional(payment, 'providerRef', attempt.providerRef); + setOptional(payment, 'nextAction', result.nextAction); + delete payment.error; + return; + } + case 'fallback': + attempt.status = 'failed'; + attempt.outcome = 'fell_back'; + attempt.error = decision.error.toJSON(); + return; + case 'fail': + attempt.status = decision.status; + attempt.outcome = 'failed'; + attempt.error = decision.error.toJSON(); + payment.status = decision.status; + payment.error = attempt.error; + setOptional(payment, 'providerRef', attempt.providerRef); + return; + case 'unresolved': + attempt.status = 'unknown'; + attempt.outcome = 'unresolved'; + attempt.error = decision.error.toJSON(); + payment.status = 'unknown'; + payment.error = attempt.error; + setOptional(payment, 'providerRef', attempt.providerRef); + return; + } + } + + async function collect(request: CollectRequest): Promise { + validate(request); + const fingerprint = fingerprintOf(request); + + const existing = await store.get(request.idempotencyKey); + if (existing) return assertSameRequest(existing, fingerprint); + + const eligible = connectors.filter((connector) => supports(connector, 'collect', request)); + const candidates = eligible.length === 0 ? [] : await routing(eligible, request); + if (candidates.length === 0) { + throw new PayenvError( + 'NO_ROUTE', + `No connector supports ${describeRoute(request)}. Check the connectors' capabilities.`, + ); + } + + const createdAt = now(); + const payment: Payment = { + id: `pay_${crypto.randomUUID()}`, + idempotencyKey: request.idempotencyKey, + operation: 'collect', + status: 'created', + amount: request.amount, + method: request.method, + attempts: [], + fingerprint, + createdAt, + updatedAt: createdAt, + }; + setOptional(payment, 'customer', request.customer); + setOptional(payment, 'description', request.description); + setOptional(payment, 'metadata', request.metadata); + + if (!(await store.create(payment))) { + // Another call with the same key won the race: it owns the payment. + const winner = await store.get(request.idempotencyKey); + if (!winner) throw new PayenvError('UNKNOWN_ERROR', 'Store lost a payment it just refused'); + return assertSameRequest(winner, fingerprint); + } + + let lastError: PayenvError | undefined; + for (const [index, connector] of candidates.entries()) { + const attemptId = `att_${crypto.randomUUID()}`; + const attempt: Attempt = { + id: attemptId, + connectorId: connector.id, + reference: attemptId, + status: 'created', + startedAt: now(), + }; + payment.attempts.push(attempt); + payment.connectorId = connector.id; + await save(payment); + emit({ type: 'attempt.started', payment, attempt }); + + const decision = await runAttempt(connector, request, payment, attempt); + apply(payment, attempt, decision); + await save(payment); + emit({ type: 'attempt.finished', payment, attempt }); + + // The safe-fallback invariant: only a proven "no money moved" lets us continue. + if (decision.kind !== 'fallback') return payment; + + lastError = decision.error; + const next = candidates[index + 1]; + if (next) { + emit({ + type: 'fallback', + payment, + from: connector.id, + to: next.id, + reason: decision.error.toJSON(), + }); + } + } + + payment.status = 'failed'; + if (lastError) payment.error = lastError.toJSON(); + await save(payment); + return payment; + } + + async function refresh(idempotencyKey: string): Promise { + const payment = await store.get(idempotencyKey); + if (!payment) { + throw new PayenvError('PAYMENT_NOT_FOUND', `No payment with key "${idempotencyKey}"`); + } + const attempt = payment.attempts.at(-1); + if (isTerminal(payment.status) || !attempt) return payment; + + const connector = connectors.find((candidate) => candidate.id === attempt.connectorId); + if (!connector) { + throw new PayenvError( + 'INVALID_REQUEST', + `Connector "${attempt.connectorId}" is not configured; cannot refresh this payment`, + ); + } + + let status: StatusResult; + try { + const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + if (attempt.providerRef !== undefined) query.providerRef = attempt.providerRef; + status = await checkStatus(connector, query); + } catch (thrown) { + throw toPayenvError(connector, thrown); + } + + if (!status.found) { + const error = new PayenvError( + 'PAYMENT_NOT_FOUND', + 'The provider has no record of this payment', + { connectorId: connector.id }, + ).toJSON(); + attempt.status = 'failed'; + attempt.error = error; + payment.status = 'failed'; + payment.error = error; + } else { + const next = status.status === 'created' ? 'pending' : status.status; + if (!canTransition(payment.status, next)) return payment; + attempt.status = next; + payment.status = next; + if (status.providerRef !== undefined) { + attempt.providerRef = status.providerRef; + payment.providerRef = status.providerRef; + } + setOptional(payment, 'nextAction', status.nextAction); + if (status.error) { + attempt.error = status.error.toJSON(); + payment.error = attempt.error; + } + } + await save(payment); + return payment; + } + + return { + collect, + getPayment: (idempotencyKey) => store.get(idempotencyKey), + refresh, + }; +} + +function classify(result: ProviderResult, connector: Connector): Classification { + switch (result.status) { + case 'created': + case 'pending': + case 'requires_action': + case 'succeeded': + return { kind: 'accept', result }; + case 'unknown': + return { + kind: 'ambiguous', + error: + result.error ?? + new PayenvError('UNKNOWN_ERROR', 'Provider returned an unknown status', { + connectorId: connector.id, + }), + }; + case 'failed': + case 'canceled': + case 'expired': { + // A failure without a reason is not proof that another provider is safe to try. + const error = + result.error ?? + new PayenvError('UNKNOWN_ERROR', `Provider reported "${result.status}" without a reason`, { + connectorId: connector.id, + retryClass: 'do_not_retry', + }); + if (error.retryClass === 'safe_to_fallback') return { kind: 'fallback', error }; + if (error.retryClass === 'ambiguous') return { kind: 'ambiguous', error }; + return { kind: 'fail', status: result.status, error }; + } + } +} + +function toPayenvError(connector: Connector, thrown: unknown): PayenvError { + if (isPayenvError(thrown)) return thrown; + if (connector.mapError) { + try { + const mapped = connector.mapError(thrown); + if (isPayenvError(mapped)) return mapped; + } catch { + // Fall through: an unmappable error is treated as ambiguous. + } + } + const message = thrown instanceof Error ? thrown.message : String(thrown); + return new PayenvError('UNKNOWN_ERROR', message, { connectorId: connector.id, cause: thrown }); +} + +function withTimeout(promise: Promise, ms: number, controller: AbortController): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort(); + reject(new PayenvError('TIMEOUT', `Provider call timed out after ${ms} ms`)); + }, ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function validate(request: CollectRequest): void { + const key = request.idempotencyKey; + if (typeof key !== 'string' || key.length === 0 || key.length > 255) { + throw new PayenvError( + 'INVALID_REQUEST', + 'idempotencyKey must be a string of 1 to 255 characters', + ); + } + assertValidMoney(request.amount); + const { method } = request; + if (method.type === 'mobile_money' && !E164.test(method.phone)) { + throw new PayenvError( + 'INVALID_PHONE', + 'Mobile money phone must be in E.164 format, e.g. "+22990000000"', + ); + } +} + +function fingerprintOf(request: CollectRequest): string { + const { amount, method } = request; + const target = + method.type === 'mobile_money' + ? [method.network, method.country, method.phone] + : method.type === 'card' + ? [method.token] + : [method.returnUrl]; + return JSON.stringify([amount.value, amount.currency, method.type, ...target]); +} + +function assertSameRequest(payment: Payment, fingerprint: string): Payment { + if (payment.fingerprint !== fingerprint) { + throw new PayenvError( + 'IDEMPOTENCY_CONFLICT', + `idempotencyKey "${payment.idempotencyKey}" was already used with a different request`, + ); + } + return payment; +} + +function describeRoute(request: CollectRequest): string { + const { method, amount } = request; + const parts: string[] = [method.type]; + if (method.type === 'mobile_money') parts.push(method.network); + if (method.country) parts.push(method.country); + parts.push(amount.currency); + return parts.join(' / '); +} + +function setOptional( + target: T, + key: K, + value: T[K] | undefined, +) { + if (value === undefined) delete target[key]; + else target[key] = value; +} + +function now(): string { + return new Date().toISOString(); +} + +function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/payment.ts b/packages/core/src/payment.ts new file mode 100644 index 0000000..ab89023 --- /dev/null +++ b/packages/core/src/payment.ts @@ -0,0 +1,50 @@ +import type { Customer, NextAction, Operation } from './connector.js'; +import type { SerializedError } from './errors.js'; +import type { PaymentMethod } from './method.js'; +import type { Money } from './money.js'; +import type { PaymentStatus } from './status.js'; + +/** How the attempt engine concluded an attempt. */ +export type AttemptOutcome = + /** The provider accepted the payment (pending, requires action, or succeeded). */ + | 'accepted' + /** Proven that no money moved; another connector was (or could be) tried. */ + | 'fell_back' + /** Failed for a reason another provider would not change. */ + | 'failed' + /** Could not determine whether money moved. */ + | 'unresolved'; + +export interface Attempt { + id: string; + connectorId: string; + reference: string; + status: PaymentStatus; + outcome?: AttemptOutcome; + providerRef?: string; + error?: SerializedError; + startedAt: string; + endedAt?: string; +} + +export interface Payment { + id: string; + idempotencyKey: string; + operation: Operation; + status: PaymentStatus; + amount: Money; + method: PaymentMethod; + customer?: Customer; + description?: string; + metadata?: Readonly>; + /** Connector currently holding the payment (the last attempt). */ + connectorId?: string; + providerRef?: string; + nextAction?: NextAction; + error?: SerializedError; + attempts: Attempt[]; + /** Fingerprint of the request, used to detect idempotency key reuse with different data. */ + fingerprint: string; + createdAt: string; + updatedAt: string; +} diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts new file mode 100644 index 0000000..e37aaa0 --- /dev/null +++ b/packages/core/src/router.ts @@ -0,0 +1,45 @@ +import type { CollectRequest, Connector, Operation } from './connector.js'; +import { methodCountry, methodNetwork } from './method.js'; + +/** Orders the connectors that support a request. The first one is tried first. */ +export type RoutingStrategy = ( + candidates: readonly Connector[], + request: CollectRequest, +) => readonly Connector[] | Promise; + +/** Whether the connector declares a capability matching the request. */ +export function supports( + connector: Connector, + operation: Operation, + request: CollectRequest, +): boolean { + const { method, amount } = request; + const country = methodCountry(method); + const network = methodNetwork(method); + + return connector + .capabilities() + .some( + (capability) => + capability.operation === operation && + capability.method === method.type && + capability.currencies.includes(amount.currency) && + (capability.countries === undefined || + (country !== undefined && capability.countries.includes(country))) && + (capability.networks === undefined || + (network !== undefined && capability.networks.includes(network))), + ); +} + +/** + * Tries connectors in a fixed order. Connectors not listed keep their registration + * order, after the listed ones. Without an order, registration order is used. + */ +export function priority(order: readonly string[] = []): RoutingStrategy { + const rank = (connector: Connector) => { + const index = order.indexOf(connector.id); + return index === -1 ? order.length : index; + }; + // Array.prototype.sort is stable, so ties keep registration order. + return (candidates) => [...candidates].sort((a, b) => rank(a) - rank(b)); +} diff --git a/packages/core/src/status.ts b/packages/core/src/status.ts new file mode 100644 index 0000000..6250197 --- /dev/null +++ b/packages/core/src/status.ts @@ -0,0 +1,47 @@ +/** + * Unified payment status. + * + * `unknown` means Payenv could not determine whether money moved. It is never + * guessed: the application must reconcile it later (see `Payenv.refresh`). + */ +export type PaymentStatus = + | 'created' + | 'pending' + | 'requires_action' + | 'succeeded' + | 'failed' + | 'canceled' + | 'expired' + | 'unknown'; + +export type TerminalStatus = Extract< + PaymentStatus, + 'succeeded' | 'failed' | 'canceled' | 'expired' +>; + +const TERMINAL: ReadonlySet = new Set([ + 'succeeded', + 'failed', + 'canceled', + 'expired', +]); + +export function isTerminal(status: PaymentStatus): status is TerminalStatus { + return TERMINAL.has(status); +} + +const TRANSITIONS: Readonly> = { + created: ['pending', 'requires_action', 'succeeded', 'failed', 'canceled', 'expired', 'unknown'], + pending: ['requires_action', 'succeeded', 'failed', 'canceled', 'expired', 'unknown'], + requires_action: ['pending', 'succeeded', 'failed', 'canceled', 'expired', 'unknown'], + unknown: ['pending', 'requires_action', 'succeeded', 'failed', 'canceled', 'expired'], + succeeded: [], + failed: [], + canceled: [], + expired: [], +}; + +/** Whether a payment may move from `from` to `to`. Terminal statuses never change. */ +export function canTransition(from: PaymentStatus, to: PaymentStatus): boolean { + return from === to || TRANSITIONS[from].includes(to); +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts new file mode 100644 index 0000000..63c668c --- /dev/null +++ b/packages/core/src/store.ts @@ -0,0 +1,34 @@ +import type { Payment } from './payment.js'; + +/** + * Persistence used by Payenv for payments and attempts. The application owns it. + * + * `create` must be atomic: when two calls race with the same idempotency key, + * exactly one of them returns `true`. This is what prevents duplicate charges + * under concurrency. + */ +export interface Store { + /** Inserts the payment if no payment has this idempotency key. Returns `false` otherwise. */ + create(payment: Payment): Promise; + get(idempotencyKey: string): Promise; + update(payment: Payment): Promise; +} + +/** In-memory store for development and tests. Data is lost on restart. */ +export function createMemoryStore(): Store { + const payments = new Map(); + return { + async create(payment) { + if (payments.has(payment.idempotencyKey)) return false; + payments.set(payment.idempotencyKey, structuredClone(payment)); + return true; + }, + async get(idempotencyKey) { + const payment = payments.get(idempotencyKey); + return payment && structuredClone(payment); + }, + async update(payment) { + payments.set(payment.idempotencyKey, structuredClone(payment)); + }, + }; +} diff --git a/packages/core/test/fallback.test.ts b/packages/core/test/fallback.test.ts new file mode 100644 index 0000000..fb20e78 --- /dev/null +++ b/packages/core/test/fallback.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import { createPayenv, PayenvError, type ProviderResult, type StatusResult } from '../src/index.js'; +import { fakeConnector, mtnRequest, pending } from './helpers.js'; + +const fast = { statusCheckDelaysMs: [0, 0, 0], attemptTimeoutMs: 50 }; + +describe('safe fallback', () => { + it('uses the first connector when it accepts the payment', async () => { + const a = fakeConnector('a', pending); + const b = fakeConnector('b', pending); + const payenv = createPayenv({ connectors: [a, b], ...fast }); + + const payment = await payenv.collect(mtnRequest()); + + expect(payment.status).toBe('pending'); + expect(payment.connectorId).toBe('a'); + expect(payment.nextAction).toEqual({ type: 'customer_confirmation', channel: 'ussd' }); + expect(b.collectCalls).toHaveLength(0); + }); + + it('falls back when the provider rejects before any money moved', async () => { + const a = fakeConnector('a', async () => { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }); + const b = fakeConnector('b', pending); + const events: string[] = []; + const payenv = createPayenv({ + connectors: [a, b], + ...fast, + onEvent: (event) => events.push(event.type), + }); + + const payment = await payenv.collect(mtnRequest()); + + expect(payment.status).toBe('pending'); + expect(payment.connectorId).toBe('b'); + expect(payment.attempts.map((attempt) => attempt.outcome)).toEqual(['fell_back', 'accepted']); + expect(events).toContain('fallback'); + }); + + it('falls back when the provider reports a failed status with a safe reason', async () => { + const a = fakeConnector('a', async () => ({ + status: 'failed', + error: new PayenvError('ROUTE_UNSUPPORTED', 'MTN not enabled on this account'), + })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.connectorId).toBe('b'); + expect(payment.status).toBe('pending'); + }); + + it('does not fall back when another provider would not change the outcome', async () => { + const a = fakeConnector('a', async () => ({ + status: 'failed', + error: new PayenvError('INSUFFICIENT_FUNDS', 'Solde insuffisant'), + })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('failed'); + expect(payment.error?.code).toBe('INSUFFICIENT_FUNDS'); + expect(b.collectCalls).toHaveLength(0); + }); + + it('does not fall back on a failure without a reason', async () => { + const a = fakeConnector('a', async () => ({ status: 'failed' })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('failed'); + expect(b.collectCalls).toHaveLength(0); + }); + + it('returns failed with the last error when every connector fell back', async () => { + const down = async (): Promise => { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }; + const payment = await createPayenv({ + connectors: [fakeConnector('a', down), fakeConnector('b', down)], + ...fast, + }).collect(mtnRequest()); + + expect(payment.status).toBe('failed'); + expect(payment.error?.code).toBe('PROVIDER_UNAVAILABLE'); + expect(payment.attempts).toHaveLength(2); + }); +}); + +describe('ambiguous attempts (double-charge protection)', () => { + const timeout = async (): Promise => { + throw new PayenvError('TIMEOUT', 'socket timeout'); + }; + + it('checks the status with the same provider and keeps the payment if it exists', async () => { + const a = fakeConnector('a', timeout, async () => ({ found: true, status: 'pending' })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('pending'); + expect(payment.connectorId).toBe('a'); + expect(a.statusCalls).toHaveLength(1); + expect(b.collectCalls).toHaveLength(0); + }); + + it('treats unexpected exceptions as ambiguous', async () => { + const a = fakeConnector( + 'a', + async () => { + throw new Error('ECONNRESET'); + }, + async () => ({ found: true, status: 'succeeded' }), + ); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('succeeded'); + expect(b.collectCalls).toHaveLength(0); + }); + + it('falls back only once the provider confirms it has no record', async () => { + const a = fakeConnector('a', timeout, async () => ({ found: false })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.connectorId).toBe('b'); + expect(a.statusCalls).toHaveLength(1); + }); + + it('keeps checking while the status stays unknown, then returns unknown', async () => { + const a = fakeConnector('a', timeout, async () => ({ found: true, status: 'unknown' })); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('unknown'); + expect(a.statusCalls).toHaveLength(3); + expect(b.collectCalls).toHaveLength(0); + }); + + it('returns unknown, without falling back, when status checks keep failing', async () => { + const a = fakeConnector('a', timeout, async () => { + throw new Error('still down'); + }); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(payment.status).toBe('unknown'); + expect(payment.error?.code).toBe('TIMEOUT'); + expect(b.collectCalls).toHaveLength(0); + }); + + it('treats a provider call that exceeds the timeout as ambiguous and aborts it', async () => { + let signal: AbortSignal | undefined; + const a = fakeConnector( + 'a', + (_request, context) => { + signal = context.signal; + return new Promise(() => {}); + }, + async () => ({ found: true, status: 'pending' }), + ); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(signal?.aborted).toBe(true); + expect(payment.status).toBe('pending'); + expect(b.collectCalls).toHaveLength(0); + }); + + it('never starts a new attempt while a previous one could still succeed (randomized)', async () => { + // `proven` tells, independently of the engine, whether the provider proved that + // no money moved. The engine's own labels are deliberately not trusted here. + type Outcome = { proven: boolean; run: () => Promise }; + type Status = { proven: boolean; run: () => Promise }; + + const outcomes: Outcome[] = [ + { proven: false, run: pending }, + { proven: false, run: async () => ({ status: 'succeeded' }) }, + { proven: false, run: async () => ({ status: 'unknown' }) }, + { proven: false, run: async () => ({ status: 'failed' }) }, + { + proven: false, + run: async () => ({ status: 'failed', error: new PayenvError('CUSTOMER_DECLINED', 'no') }), + }, + { + proven: true, + run: async () => ({ status: 'failed', error: new PayenvError('RATE_LIMITED', 'slow') }), + }, + { + proven: true, + run: async () => { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }, + }, + { + proven: false, + run: async () => { + throw new PayenvError('TIMEOUT', 'timeout'); + }, + }, + { + proven: false, + run: async () => { + throw new Error('boom'); + }, + }, + ]; + const statuses: Status[] = [ + { proven: true, run: async () => ({ found: false }) }, + { proven: false, run: async () => ({ found: true, status: 'pending' }) }, + { proven: false, run: async () => ({ found: true, status: 'unknown' }) }, + { proven: false, run: async () => ({ found: true, status: 'succeeded' }) }, + { + proven: true, + run: async () => ({ + found: true, + status: 'failed', + error: new PayenvError('PROVIDER_UNAVAILABLE', 'rejected'), + }), + }, + { + proven: false, + run: async () => { + throw new Error('status down'); + }, + }, + ]; + + let seed = 42; + const random = (max: number) => { + seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31; + return seed % max; + }; + const pick = (items: readonly T[]): T => items[random(items.length)] as T; + + let fallbacksSeen = 0; + for (let run = 0; run < 500; run++) { + const provenSafe = new Map(); + const connectors = ['a', 'b', 'c'].map((id) => + fakeConnector( + id, + () => { + const outcome = pick(outcomes); + provenSafe.set(id, outcome.proven); + return outcome.run(); + }, + () => { + const status = pick(statuses); + if (status.proven) provenSafe.set(id, true); + return status.run(); + }, + ), + ); + const payment = await createPayenv({ connectors, ...fast }).collect(mtnRequest()); + + // Every connector followed by another attempt must have proven no money moved. + for (const attempt of payment.attempts.slice(0, -1)) { + expect(provenSafe.get(attempt.connectorId)).toBe(true); + fallbacksSeen += 1; + } + for (const connector of connectors) { + expect(connector.collectCalls.length).toBeLessThanOrEqual(1); + } + } + // Make sure the randomized scenarios actually exercise fallbacks. + expect(fallbacksSeen).toBeGreaterThan(50); + }); +}); diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts new file mode 100644 index 0000000..4bd4533 --- /dev/null +++ b/packages/core/test/helpers.ts @@ -0,0 +1,69 @@ +import type { + AttemptContext, + Capability, + CollectRequest, + Connector, + ProviderResult, + StatusResult, +} from '../src/index.js'; + +type CollectBehavior = ( + request: CollectRequest, + context: AttemptContext, +) => Promise; +type StatusBehavior = (reference: string) => Promise; + +export interface FakeConnector extends Connector { + collectCalls: AttemptContext[]; + statusCalls: string[]; +} + +export const MTN_BJ_XOF: Capability = { + operation: 'collect', + method: 'mobile_money', + currencies: ['XOF'], + countries: ['BJ'], + networks: ['mtn', 'moov'], +}; + +export function fakeConnector( + id: string, + collect: CollectBehavior, + getStatus: StatusBehavior = async () => ({ found: false }), + capabilities: readonly Capability[] = [MTN_BJ_XOF], +): FakeConnector { + const connector: FakeConnector = { + id, + collectCalls: [], + statusCalls: [], + capabilities: () => capabilities, + async collect(request, context) { + connector.collectCalls.push(context); + return collect(request, context); + }, + async getStatus(query) { + connector.statusCalls.push(query.reference); + return getStatus(query.reference); + }, + }; + return connector; +} + +let counter = 0; + +export function mtnRequest(overrides: Partial = {}): CollectRequest { + counter += 1; + return { + amount: { value: 5000, currency: 'XOF' }, + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: `order_${counter}`, + ...overrides, + }; +} + +export const pending = async (): Promise => ({ + status: 'pending', + providerRef: 'prov_1', + nextAction: { type: 'customer_confirmation', channel: 'ussd' }, +}); diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts new file mode 100644 index 0000000..3543825 --- /dev/null +++ b/packages/core/test/model.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + canTransition, + currencyExponent, + fromMajor, + isTerminal, + money, + PayenvError, +} from '../src/index.js'; + +describe('money', () => { + it('knows currency exponents', () => { + expect(currencyExponent('XOF')).toBe(0); + expect(currencyExponent('EUR')).toBe(2); + expect(currencyExponent('TND')).toBe(3); + }); + + it('converts major units without floating-point errors', () => { + expect(fromMajor('5000', 'XOF')).toEqual({ value: 5000, currency: 'XOF' }); + expect(fromMajor('12.5', 'EUR')).toEqual({ value: 1250, currency: 'EUR' }); + expect(fromMajor('0.29', 'EUR')).toEqual({ value: 29, currency: 'EUR' }); + }); + + it('rejects too many decimals, invalid currencies, and non-positive amounts', () => { + expect(() => fromMajor('10.5', 'XOF')).toThrow(PayenvError); + expect(() => money(100, 'xof')).toThrow(PayenvError); + expect(() => money(0, 'XOF')).toThrow(PayenvError); + expect(() => money(-5, 'XOF')).toThrow(PayenvError); + }); +}); + +describe('status', () => { + it('identifies terminal statuses', () => { + expect(isTerminal('succeeded')).toBe(true); + expect(isTerminal('pending')).toBe(false); + expect(isTerminal('unknown')).toBe(false); + }); + + it('never leaves a terminal status', () => { + expect(canTransition('succeeded', 'failed')).toBe(false); + expect(canTransition('failed', 'succeeded')).toBe(false); + expect(canTransition('pending', 'succeeded')).toBe(true); + expect(canTransition('unknown', 'succeeded')).toBe(true); + }); +}); + +describe('errors', () => { + it('assigns default retry classes and allows overrides', () => { + expect(new PayenvError('TIMEOUT', 'x').retryClass).toBe('ambiguous'); + expect(new PayenvError('PROVIDER_UNAVAILABLE', 'x').retryClass).toBe('safe_to_fallback'); + expect(new PayenvError('INSUFFICIENT_FUNDS', 'x').retryClass).toBe('do_not_retry'); + expect(new PayenvError('TIMEOUT', 'x', { retryClass: 'safe_to_fallback' }).retryClass).toBe( + 'safe_to_fallback', + ); + }); + + it('does not serialize the raw provider payload', () => { + const error = new PayenvError('UNKNOWN_ERROR', 'x', { raw: { secret: 'sk_live' } }); + expect(JSON.stringify(error)).not.toContain('sk_live'); + }); +}); diff --git a/packages/core/test/payenv.test.ts b/packages/core/test/payenv.test.ts new file mode 100644 index 0000000..fd661cd --- /dev/null +++ b/packages/core/test/payenv.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryStore, createPayenv, PayenvError, priority } from '../src/index.js'; +import { fakeConnector, mtnRequest, pending } from './helpers.js'; + +const fast = { statusCheckDelaysMs: [0], attemptTimeoutMs: 50 }; + +describe('idempotency', () => { + it('returns the same payment for the same key without charging again', async () => { + const a = fakeConnector('a', pending); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + + const first = await payenv.collect(request); + const second = await payenv.collect(request); + + expect(second.id).toBe(first.id); + expect(a.collectCalls).toHaveLength(1); + }); + + it('charges only once when two calls race with the same key', async () => { + const a = fakeConnector('a', pending); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + + const [first, second] = await Promise.all([payenv.collect(request), payenv.collect(request)]); + + expect(second.id).toBe(first.id); + expect(a.collectCalls).toHaveLength(1); + }); + + it('rejects a reused key with a different amount', async () => { + const payenv = createPayenv({ connectors: [fakeConnector('a', pending)], ...fast }); + const request = mtnRequest(); + await payenv.collect(request); + + await expect( + payenv.collect({ ...request, amount: { value: 9000, currency: 'XOF' } }), + ).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT' }); + }); + + it('gives each attempt a unique reference and idempotency key', async () => { + const a = fakeConnector('a', async () => { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }); + const b = fakeConnector('b', pending); + await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + const [first] = a.collectCalls; + const [second] = b.collectCalls; + expect(first?.reference).not.toBe(second?.reference); + expect(first?.idempotencyKey).not.toBe(second?.idempotencyKey); + }); +}); + +describe('routing', () => { + it('skips connectors that do not support the route', async () => { + const cardOnly = fakeConnector('cards', pending, undefined, [ + { operation: 'collect', method: 'card', currencies: ['XOF'] }, + ]); + const momo = fakeConnector('momo', pending); + const payment = await createPayenv({ connectors: [cardOnly, momo], ...fast }).collect( + mtnRequest(), + ); + + expect(payment.connectorId).toBe('momo'); + expect(cardOnly.collectCalls).toHaveLength(0); + }); + + it('throws NO_ROUTE when no connector supports the network', async () => { + const payenv = createPayenv({ connectors: [fakeConnector('a', pending)], ...fast }); + await expect( + payenv.collect( + mtnRequest({ + method: { type: 'mobile_money', network: 'wave', country: 'SN', phone: '+221770000000' }, + }), + ), + ).rejects.toMatchObject({ code: 'NO_ROUTE' }); + }); + + it('follows the priority order', async () => { + const a = fakeConnector('a', pending); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ + connectors: [a, b], + routing: priority(['b', 'a']), + ...fast, + }).collect(mtnRequest()); + + expect(payment.connectorId).toBe('b'); + }); +}); + +describe('validation', () => { + const payenv = createPayenv({ connectors: [fakeConnector('a', pending)], ...fast }); + + it('rejects non-integer amounts', async () => { + await expect( + payenv.collect(mtnRequest({ amount: { value: 12.5, currency: 'XOF' } })), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + }); + + it('rejects phones that are not E.164', async () => { + await expect( + payenv.collect( + mtnRequest({ + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '90000000' }, + }), + ), + ).rejects.toMatchObject({ code: 'INVALID_PHONE' }); + }); + + it('rejects duplicate connector ids', () => { + expect(() => + createPayenv({ connectors: [fakeConnector('a', pending), fakeConnector('a', pending)] }), + ).toThrow(PayenvError); + }); +}); + +describe('refresh', () => { + it('updates a pending payment from the provider', async () => { + let status: 'pending' | 'succeeded' = 'pending'; + const a = fakeConnector('a', pending, async () => ({ found: true, status })); + const payenv = createPayenv({ connectors: [a], store: createMemoryStore(), ...fast }); + const request = mtnRequest(); + await payenv.collect(request); + + status = 'succeeded'; + const refreshed = await payenv.refresh(request.idempotencyKey); + + expect(refreshed.status).toBe('succeeded'); + expect((await payenv.getPayment(request.idempotencyKey))?.status).toBe('succeeded'); + }); + + it('never changes a terminal payment', async () => { + const a = fakeConnector( + 'a', + async () => ({ status: 'succeeded' }), + async () => ({ found: true, status: 'failed' }), + ); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + await payenv.collect(request); + + expect((await payenv.refresh(request.idempotencyKey)).status).toBe('succeeded'); + expect(a.statusCalls).toHaveLength(0); + }); + + it('ignores errors thrown by event handlers', async () => { + const payenv = createPayenv({ + connectors: [fakeConnector('a', pending)], + ...fast, + onEvent: () => { + throw new Error('bad handler'); + }, + }); + expect((await payenv.collect(mtnRequest())).status).toBe('pending'); + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7d406e7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1191 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.5.14 + version: 2.5.14 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.2 + version: 5.0.2(vite@8.3.1(esbuild@0.27.7)) + +packages: + + '@biomejs/biome@2.5.14': + resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.14': + resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.14': + resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.14': + resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.5.14': + resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.5.14': + resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.5.14': + resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.5.14': + resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.14': + resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.5.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.14 + '@biomejs/cli-darwin-x64': 2.5.14 + '@biomejs/cli-linux-arm64': 2.5.14 + '@biomejs/cli-linux-arm64-musl': 2.5.14 + '@biomejs/cli-linux-x64': 2.5.14 + '@biomejs/cli-linux-x64-musl': 2.5.14 + '@biomejs/cli-win32-arm64': 2.5.14 + '@biomejs/cli-win32-x64': 2.5.14 + + '@biomejs/cli-darwin-arm64@2.5.14': + optional: true + + '@biomejs/cli-darwin-x64@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64@2.5.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-x64@2.5.14': + optional: true + + '@biomejs/cli-win32-arm64@2.5.14': + optional: true + + '@biomejs/cli-win32-x64@2.5.14': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/mocker@5.0.2(vite@8.3.1(esbuild@0.27.7))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(esbuild@0.27.7) + + '@vitest/spy@5.0.2': {} + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + optional: true + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + source-map-js@1.2.1: {} + + std-env@4.2.0: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + vite@8.3.1(esbuild@0.27.7): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + esbuild: 0.27.7 + fsevents: 2.3.3 + + vitest@5.0.2(vite@8.3.1(esbuild@0.27.7)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@8.3.1(esbuild@0.27.7)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(esbuild@0.27.7) + why-is-node-running: 3.2.2 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ec7f8c9 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6e6e3bc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/*/test/**/*.test.ts'], + }, +});