From c7d4df64c68f79d5df4b49bfa26872bad4f32dc1 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 29 May 2026 23:20:10 +0100 Subject: [PATCH 01/16] Initial project setup with TypeScript SDK for OMOPHub API, including configuration files, client implementation, and CI/CD workflows. Added .gitignore, README, changelog, and basic error handling. Implemented request methods with retry logic and pagination support. --- .github/workflows/ci.yml | 27 + .github/workflows/publish.yml | 27 + .gitignore | 13 + .nvmrc | 1 + CHANGELOG.md | 36 + README.md | 42 + biome.json | 51 + package-lock.json | 3160 +++++++++++++++++ package.json | 65 + renovate.json | 4 + src/client.ts | 250 ++ src/common/interfaces/api-envelope.ts | 18 + src/common/interfaces/delete-options.ts | 3 + src/common/interfaces/get-options.ts | 3 + src/common/interfaces/index.ts | 15 + src/common/interfaces/pagination.ts | 28 + src/common/interfaces/patch-options.ts | 3 + src/common/interfaces/per-call-options.ts | 23 + src/common/interfaces/post-options.ts | 8 + src/common/interfaces/put-options.ts | 3 + src/common/interfaces/require-at-least-one.ts | 10 + src/common/interfaces/require-exactly-one.ts | 15 + src/common/interfaces/vocab-release.ts | 8 + src/common/utils/backoff.ts | 48 + src/common/utils/build-query.ts | 33 + src/common/utils/env.ts | 12 + src/common/utils/index.ts | 8 + src/common/utils/merge-headers.ts | 15 + src/common/utils/parse-error.ts | 131 + src/common/utils/sleep.ts | 21 + src/common/utils/to-snake-case.ts | 37 + src/common/utils/unwrap-envelope.ts | 46 + src/errors.ts | 30 + src/index.ts | 34 + src/interfaces.ts | 64 + src/version.ts | 5 + src/vocabularies/interfaces/index.ts | 2 + .../interfaces/list-vocabularies-options.ts | 8 + src/vocabularies/interfaces/vocabulary.ts | 28 + src/vocabularies/vocabularies.ts | 37 + test/client-http.test.ts | 317 ++ test/client.test.ts | 93 + test/common/utils/backoff.test.ts | 64 + test/common/utils/build-query.test.ts | 52 + test/common/utils/parse-error.test.ts | 127 + test/common/utils/to-snake-case.test.ts | 59 + test/common/utils/unwrap-envelope.test.ts | 57 + test/errors.test.ts | 27 + test/fixtures/index.ts | 46 + test/helpers/mock-fetch.ts | 73 + test/version.test.ts | 8 + test/vocabularies/vocabularies.test.ts | 98 + tsconfig.json | 23 + vitest.config.ts | 14 + 54 files changed, 5430 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 biome.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 renovate.json create mode 100644 src/client.ts create mode 100644 src/common/interfaces/api-envelope.ts create mode 100644 src/common/interfaces/delete-options.ts create mode 100644 src/common/interfaces/get-options.ts create mode 100644 src/common/interfaces/index.ts create mode 100644 src/common/interfaces/pagination.ts create mode 100644 src/common/interfaces/patch-options.ts create mode 100644 src/common/interfaces/per-call-options.ts create mode 100644 src/common/interfaces/post-options.ts create mode 100644 src/common/interfaces/put-options.ts create mode 100644 src/common/interfaces/require-at-least-one.ts create mode 100644 src/common/interfaces/require-exactly-one.ts create mode 100644 src/common/interfaces/vocab-release.ts create mode 100644 src/common/utils/backoff.ts create mode 100644 src/common/utils/build-query.ts create mode 100644 src/common/utils/env.ts create mode 100644 src/common/utils/index.ts create mode 100644 src/common/utils/merge-headers.ts create mode 100644 src/common/utils/parse-error.ts create mode 100644 src/common/utils/sleep.ts create mode 100644 src/common/utils/to-snake-case.ts create mode 100644 src/common/utils/unwrap-envelope.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/interfaces.ts create mode 100644 src/version.ts create mode 100644 src/vocabularies/interfaces/index.ts create mode 100644 src/vocabularies/interfaces/list-vocabularies-options.ts create mode 100644 src/vocabularies/interfaces/vocabulary.ts create mode 100644 src/vocabularies/vocabularies.ts create mode 100644 test/client-http.test.ts create mode 100644 test/client.test.ts create mode 100644 test/common/utils/backoff.test.ts create mode 100644 test/common/utils/build-query.test.ts create mode 100644 test/common/utils/parse-error.test.ts create mode 100644 test/common/utils/to-snake-case.test.ts create mode 100644 test/common/utils/unwrap-envelope.test.ts create mode 100644 test/errors.test.ts create mode 100644 test/fixtures/index.ts create mode 100644 test/helpers/mock-fetch.ts create mode 100644 test/version.test.ts create mode 100644 test/vocabularies/vocabularies.test.ts create mode 100644 tsconfig.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..8c33aff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + - run: npm run build + - run: npm run lint + - run: npm run typecheck + - run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4b20e96 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish to npm + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + + - run: npm ci + - run: npm run build + - run: npm test + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca5742c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +coverage/ +.env +.env.local +.env.*.local +*.log +npm-debug.log* +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9583b16 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial scaffolding: `OMOPHub` client skeleton with API key + base URL resolution, env-var precedence, and constructor-time validation. +- TypeScript build (ES2022, NodeNext, strict mode). +- Biome v2 lint + format. +- Vitest test runner with v8 coverage and a 90% statements threshold. +- CI workflow on Node 22 and 24 (`build`, `lint`, `typecheck`, `test`). +- npm publish workflow triggered by GitHub release, with provenance attestation. +- HTTP layer: `get` / `post` / `patch` / `put` / `delete` methods on `OMOPHub` with retry on 429 + 502/503/504 + network errors. Full-jitter exponential backoff (500ms → 8s), `Retry-After` honoured up to 60s. +- Discriminated `Response = { data, error, meta, headers }` return type — errors never throw from network paths. +- 16-code `OMOPHUB_ERROR_CODE_KEY` union with stable codes mapped from HTTP status + server-provided error codes. +- `OMOPHubError` (thrown only on constructor misuse) and `OMOPHubIteratorError` (thrown from future async iterators). +- Common request-option interfaces: `PerCallOptions`, `GetOptions`, `PostOptions` (with `idempotencyKey`), `PatchOptions`, `PutOptions`, `DeleteOptions`. +- Pagination types: `PaginationOptions`, `PaginationMeta`, `PaginatedData`. +- Vocab-release mixin + utility types (`RequireAtLeastOne`, `RequireExactlyOne`). +- Query builder: camelCase → snake_case, array → comma-join, null/undefined dropped. +- Envelope unwrap: tolerates both `{ success, data, meta }` and raw payload bodies. +- AbortSignal composition: client timeout + caller signal merged via `AbortSignal.any`; caller aborts propagate as thrown `AbortError`, timeouts return `timeout_error`. +- `X-Vocab-Version` header injection when `vocabVersion` option is set. +- First resource: `client.vocabularies.list()` with snake_case query serialisation, pagination, and error mapping. +- Test fixtures (`DIABETES_CONCEPT_ID` etc.) and mock-fetch helpers for `vi.fn`-based testing without external mock libraries. + +### Changed + +- Retries are now gated by `isRetryableRequest(method, headers)`: idempotent verbs (GET/HEAD/OPTIONS/PUT/DELETE) always retry, but POST/PATCH only retry when an `Idempotency-Key` header is set. Prevents accidental duplicate writes on transient failures. +- Response body is drained via `response.body?.cancel()` before sleeping for a retry, releasing the undici connection back to the pool. +- `publishConfig.access` set to `public` so scoped-package publishes don't fall through to npm's default restricted access. + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..884a0b8 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# @omophub/omophub-node + +Official Node.js / TypeScript SDK for the [OMOPHub API](https://omophub.com) — search, lookup, map, and navigate OHDSI medical vocabularies (SNOMED, ICD10, RxNorm, LOINC, and 100+ more) from JavaScript. + +> **Status — v0.0.x scaffold.** Public API surfaces (concepts, search, vocabularies, hierarchy, mappings, FHIR) ship in upcoming releases. See [`docs/omophub-node-sdk-implementation-plan.md`](https://github.com/OMOPHub/oh-platform) in the platform repo for the roadmap. + +## Install + +```bash +npm install @omophub/omophub-node +``` + +Requires Node ≥ 20. Runs in Node, modern browsers (CORS permitting), and edge runtimes (Cloudflare Workers, Vercel Edge). + +## Quick start + +```ts +import { OMOPHub } from '@omophub/omophub-node'; + +const client = new OMOPHub(process.env.OMOPHUB_API_KEY); + +// Coming in v0.1.0: +// const { data, error } = await client.concepts.get({ conceptId: 201826 }); +// if (error) throw new Error(error.message); +// console.log(data.concept_name); // → "Type 2 diabetes mellitus" +``` + +## Configuration + +| Option | Env var | Default | +|---|---|---| +| `apiKey` (1st constructor arg) | `OMOPHUB_API_KEY` | — (required) | +| `baseUrl` | `OMOPHUB_API_URL` | `https://api.omophub.com/v1` | +| `timeoutMs` | — | `30000` | +| `maxRetries` | — | `3` | +| `vocabVersion` | — | unset (server default) | +| `userAgent` | — | `omophub-node/` | +| `fetch` | — | `globalThis.fetch` | + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..7c48783 --- /dev/null +++ b/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "files": { + "includes": ["src/**", "test/**"] + }, + "overrides": [ + { + "includes": ["test/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d89e1c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3160 @@ +{ + "name": "@omophub/omophub-node", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@omophub/omophub-node", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^25.0.3", + "@vitest/coverage-v8": "^3.2.4", + "tsx": "^4.19.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4d8fec --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "@omophub/omophub-node", + "version": "0.0.1", + "description": "Official OMOPHub Node.js / TypeScript SDK for the OHDSI medical vocabularies API - search, lookup, map, and navigate concepts across SNOMED, ICD10, RxNorm, LOINC, and more.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "biome check src/ test/", + "lint:fix": "biome check --write src/ test/", + "typecheck": "tsc --noEmit", + "format": "biome format --write src/ test/", + "format:check": "biome format --check src/ test/", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "omophub", + "ohdsi", + "omop", + "medical-vocabularies", + "snomed", + "icd10", + "rxnorm", + "loinc", + "healthcare", + "clinical-data", + "vocabulary-mapping", + "concept-mapping", + "fhir", + "athena", + "sdk" + ], + "author": "OMOPHub", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/OMOPHub/omophub-node.git" + }, + "bugs": { + "url": "https://github.com/OMOPHub/omophub-node/issues" + }, + "homepage": "https://omophub.com", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^25.0.3", + "@vitest/coverage-v8": "^3.2.4", + "tsx": "^4.19.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..22a9943 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"] +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..47824d6 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,250 @@ +import type { DeleteOptions } from './common/interfaces/delete-options.js'; +import type { GetOptions } from './common/interfaces/get-options.js'; +import type { PatchOptions } from './common/interfaces/patch-options.js'; +import type { HeadersInit, PerCallOptions } from './common/interfaces/per-call-options.js'; +import type { PostOptions } from './common/interfaces/post-options.js'; +import type { PutOptions } from './common/interfaces/put-options.js'; +import { backoffMs, isRetryableStatus } from './common/utils/backoff.js'; +import { appendQuery, buildQuery } from './common/utils/build-query.js'; +import { envOrUndefined } from './common/utils/env.js'; +import { mergeHeaders } from './common/utils/merge-headers.js'; +import { connectionError, parseErrorResponse, timeoutError } from './common/utils/parse-error.js'; +import { sleep } from './common/utils/sleep.js'; +import { unwrapEnvelope } from './common/utils/unwrap-envelope.js'; +import { OMOPHubError } from './errors.js'; +import type { Response as OMOPHubResponse } from './interfaces.js'; +import { __version__ } from './version.js'; +import { Vocabularies } from './vocabularies/vocabularies.js'; + +const DEFAULT_BASE_URL = 'https://api.omophub.com/v1'; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_USER_AGENT = `omophub-node/${__version__}`; + +export interface OMOPHubOptions { + /** Base URL of the OMOPHub API. Defaults to env `OMOPHUB_API_URL` or the production URL. */ + baseUrl?: string; + /** Per-request timeout in milliseconds. Default 30000. Set to 0 to disable. */ + timeoutMs?: number; + /** + * Retry attempts on retryable failures (429, 502, 503, 504, and transient + * network errors) — see `isRetryableStatus`. Default 3. Set to 0 to disable. + */ + maxRetries?: number; + /** Pin a vocabulary version via the `X-Vocab-Version` header on every request. */ + vocabVersion?: string; + /** Override the SDK user-agent string. */ + userAgent?: string; + /** Inject a custom fetch implementation (proxy, instrumentation). Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; +} + +/** + * Entry point for the OMOPHub SDK. + * + * ```ts + * import { OMOPHub } from '@omophub/omophub-node'; + * + * const client = new OMOPHub(process.env.OMOPHUB_API_KEY); + * const { data, error } = await client.vocabularies.list({ pageSize: 5 }); + * if (error) throw new Error(error.message); + * console.log(data.data.map((v) => v.vocabulary_id)); + * ``` + */ +export class OMOPHub { + readonly baseUrl: string; + readonly timeoutMs: number; + readonly maxRetries: number; + readonly userAgent: string; + readonly vocabVersion: string | undefined; + readonly #apiKey: string; + readonly #headers: Headers; + readonly #fetch: typeof fetch; + + readonly vocabularies: Vocabularies; + + constructor(apiKey?: string, options: OMOPHubOptions = {}) { + const resolvedKey = apiKey ?? envOrUndefined('OMOPHUB_API_KEY'); + if (!resolvedKey) { + throw new OMOPHubError( + 'Missing API key. Pass it to the constructor: new OMOPHub("oh_...") or set OMOPHUB_API_KEY.', + ); + } + this.#apiKey = resolvedKey; + this.baseUrl = options.baseUrl ?? envOrUndefined('OMOPHUB_API_URL') ?? DEFAULT_BASE_URL; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT; + this.vocabVersion = options.vocabVersion; + + this.#headers = new Headers({ + Authorization: `Bearer ${this.#apiKey}`, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + if (this.vocabVersion) this.#headers.set('X-Vocab-Version', this.vocabVersion); + + const fetchImpl = options.fetch ?? globalThis.fetch; + this.#fetch = fetchImpl.bind(globalThis); + + this.vocabularies = new Vocabularies(this); + } + + async get(path: string, options: GetOptions = {}): Promise> { + return this.#dispatch(path, 'GET', undefined, options); + } + + async post( + path: string, + body?: unknown, + options: PostOptions = {}, + ): Promise> { + const { idempotencyKey, ...rest } = options; + let mergedHeadersInit: HeadersInit | undefined = rest.headers; + if (idempotencyKey) { + const headers = new Headers(rest.headers); + headers.set('Idempotency-Key', idempotencyKey); + mergedHeadersInit = headers; + } + return this.#dispatch(path, 'POST', body, { ...rest, headers: mergedHeadersInit }); + } + + async patch( + path: string, + body?: unknown, + options: PatchOptions = {}, + ): Promise> { + return this.#dispatch(path, 'PATCH', body, options); + } + + async put( + path: string, + body?: unknown, + options: PutOptions = {}, + ): Promise> { + return this.#dispatch(path, 'PUT', body, options); + } + + async delete( + path: string, + body?: unknown, + options: DeleteOptions = {}, + ): Promise> { + return this.#dispatch(path, 'DELETE', body, options); + } + + async #dispatch( + path: string, + method: string, + body: unknown, + options: PerCallOptions, + ): Promise> { + const query = buildQuery(options.query); + const url = `${this.baseUrl}${appendQuery(path, query)}`; + const headers = mergeHeaders(this.#headers, options.headers); + + let attempt = 0; + while (true) { + const init: RequestInit = { method, headers }; + if (body !== undefined && method !== 'GET') { + init.body = JSON.stringify(body); + } + + const timeoutController = this.timeoutMs > 0 ? new AbortController() : null; + const timer = timeoutController + ? setTimeout(() => timeoutController.abort(), this.timeoutMs) + : null; + init.signal = composeSignals(timeoutController?.signal, options.signal); + + try { + const response = await this.#fetch(url, init); + if (timer) clearTimeout(timer); + const responseHeaders = headersToRecord(response.headers); + + if (response.ok) { + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + parsed = null; + } + const { data, meta } = unwrapEnvelope(parsed); + return { data, error: null, meta, headers: responseHeaders }; + } + + if ( + isRetryableStatus(response.status) && + attempt < this.maxRetries && + isRetryableRequest(method, headers) + ) { + const retryAfter = response.headers.get('retry-after'); + // Drain the body so undici releases the connection back to the pool + // before we sleep and reuse the agent for the next attempt. + await response.body?.cancel(); + await sleep(backoffMs(attempt, retryAfter)); + attempt++; + continue; + } + + const error = await parseErrorResponse(response); + return { data: null, error, meta: null, headers: responseHeaders }; + } catch (err) { + if (timer) clearTimeout(timer); + if (isTimeoutAbort(err, timeoutController)) { + return { data: null, error: timeoutError(), meta: null, headers: null }; + } + if (isCallerAbort(err, options.signal)) { + throw err; + } + if (attempt < this.maxRetries && isRetryableRequest(method, headers)) { + await sleep(backoffMs(attempt, null)); + attempt++; + continue; + } + return { data: null, error: connectionError(err), meta: null, headers: null }; + } + } + } +} + +function composeSignals( + timeoutSignal: AbortSignal | undefined, + callerSignal: AbortSignal | undefined, +): AbortSignal | undefined { + if (timeoutSignal && callerSignal) return AbortSignal.any([timeoutSignal, callerSignal]); + return timeoutSignal ?? callerSignal; +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; +} + +function isTimeoutAbort(err: unknown, controller: AbortController | null): boolean { + if (!controller) return false; + if (!(err instanceof Error)) return false; + return err.name === 'AbortError' && controller.signal.aborted; +} + +function isCallerAbort(err: unknown, callerSignal: AbortSignal | undefined): boolean { + if (!callerSignal) return false; + if (!(err instanceof Error)) return false; + return err.name === 'AbortError' && callerSignal.aborted; +} + +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); + +/** + * A request is safe to retry when its HTTP verb is idempotent (RFC 7231) + * or when the caller has supplied an `Idempotency-Key` header — at which + * point the server can deduplicate on its side. POST and PATCH without a + * key are NOT retried, since retry could create duplicates. + */ +function isRetryableRequest(method: string, headers: Headers): boolean { + if (IDEMPOTENT_METHODS.has(method)) return true; + return headers.has('Idempotency-Key'); +} diff --git a/src/common/interfaces/api-envelope.ts b/src/common/interfaces/api-envelope.ts new file mode 100644 index 0000000..02f7e1f --- /dev/null +++ b/src/common/interfaces/api-envelope.ts @@ -0,0 +1,18 @@ +export interface ApiSuccessEnvelope { + success: true; + data: T; + meta?: Record; +} + +export interface ApiErrorEnvelope { + success: false; + error: { + code?: string; + name?: string; + message: string; + details?: Record; + }; + meta?: Record; +} + +export type ApiEnvelope = ApiSuccessEnvelope | ApiErrorEnvelope; diff --git a/src/common/interfaces/delete-options.ts b/src/common/interfaces/delete-options.ts new file mode 100644 index 0000000..506b403 --- /dev/null +++ b/src/common/interfaces/delete-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type DeleteOptions = PerCallOptions; diff --git a/src/common/interfaces/get-options.ts b/src/common/interfaces/get-options.ts new file mode 100644 index 0000000..259e53b --- /dev/null +++ b/src/common/interfaces/get-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type GetOptions = PerCallOptions; diff --git a/src/common/interfaces/index.ts b/src/common/interfaces/index.ts new file mode 100644 index 0000000..58b3c31 --- /dev/null +++ b/src/common/interfaces/index.ts @@ -0,0 +1,15 @@ +export type { + ApiEnvelope, + ApiErrorEnvelope, + ApiSuccessEnvelope, +} from './api-envelope.js'; +export type { DeleteOptions } from './delete-options.js'; +export type { GetOptions } from './get-options.js'; +export type { PaginatedData, PaginationMeta, PaginationOptions } from './pagination.js'; +export type { PatchOptions } from './patch-options.js'; +export type { PerCallOptions, QueryValue } from './per-call-options.js'; +export type { IdempotentRequest, PostOptions } from './post-options.js'; +export type { PutOptions } from './put-options.js'; +export type { RequireAtLeastOne } from './require-at-least-one.js'; +export type { RequireExactlyOne } from './require-exactly-one.js'; +export type { VocabReleaseMixin } from './vocab-release.js'; diff --git a/src/common/interfaces/pagination.ts b/src/common/interfaces/pagination.ts new file mode 100644 index 0000000..7ff5e99 --- /dev/null +++ b/src/common/interfaces/pagination.ts @@ -0,0 +1,28 @@ +export interface PaginationOptions { + /** 1-based page number. Default 1. */ + page?: number; + /** Items per page. Max 1000. Default varies per endpoint (10–100). */ + pageSize?: number; +} + +/** + * Pagination metadata returned by paginated endpoints. Field names are + * snake_case to match the wire format — no translation applied. + */ +export interface PaginationMeta { + page: number; + page_size: number; + total_items: number; + total_pages: number; + has_next: boolean; + has_previous: boolean; +} + +/** + * Response shape for paginated endpoints. The data array sits at `data`; + * pagination metadata is nested under `meta.pagination`. + */ +export interface PaginatedData { + data: T[]; + meta: { pagination: PaginationMeta }; +} diff --git a/src/common/interfaces/patch-options.ts b/src/common/interfaces/patch-options.ts new file mode 100644 index 0000000..c4da60d --- /dev/null +++ b/src/common/interfaces/patch-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type PatchOptions = PerCallOptions; diff --git a/src/common/interfaces/per-call-options.ts b/src/common/interfaces/per-call-options.ts new file mode 100644 index 0000000..f792208 --- /dev/null +++ b/src/common/interfaces/per-call-options.ts @@ -0,0 +1,23 @@ +export type QueryValue = string | number | boolean | string[] | number[] | null | undefined; + +/** + * Local stand-in for the DOM `HeadersInit`. Structurally compatible with + * the global Headers constructor (provided by `@types/node` via + * `undici-types`). Kept narrow on purpose — we surface it on + * `PerCallOptions.headers` so consumers don't need DOM lib types. + */ +export type HeadersInit = Headers | Record | [string, string][]; + +/** + * Common options accepted by every SDK method as its trailing argument. + * Lets callers override per-request headers, add query-string params, or + * pass an AbortSignal without touching the client. + */ +export interface PerCallOptions { + /** Per-call header overrides. Merged onto the client's default headers. */ + headers?: HeadersInit; + /** Extra query-string parameters. Keys are camelCase → snake_case at the wire boundary. */ + query?: Record; + /** AbortSignal to cancel the request. Composed with the client's internal timeout signal. */ + signal?: AbortSignal; +} diff --git a/src/common/interfaces/post-options.ts b/src/common/interfaces/post-options.ts new file mode 100644 index 0000000..4fa6727 --- /dev/null +++ b/src/common/interfaces/post-options.ts @@ -0,0 +1,8 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export interface IdempotentRequest { + /** Optional `Idempotency-Key` header value. POST-only. */ + idempotencyKey?: string; +} + +export type PostOptions = PerCallOptions & IdempotentRequest; diff --git a/src/common/interfaces/put-options.ts b/src/common/interfaces/put-options.ts new file mode 100644 index 0000000..46ed37e --- /dev/null +++ b/src/common/interfaces/put-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type PutOptions = PerCallOptions; diff --git a/src/common/interfaces/require-at-least-one.ts b/src/common/interfaces/require-at-least-one.ts new file mode 100644 index 0000000..ae89da0 --- /dev/null +++ b/src/common/interfaces/require-at-least-one.ts @@ -0,0 +1,10 @@ +/** + * Forces the consumer to set at least one of the given keys. + * + * ```ts + * type Options = RequireAtLeastOne<{ html: string; text: string; react: ReactNode }>; + * ``` + */ +export type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>>; +}[keyof T]; diff --git a/src/common/interfaces/require-exactly-one.ts b/src/common/interfaces/require-exactly-one.ts new file mode 100644 index 0000000..529db51 --- /dev/null +++ b/src/common/interfaces/require-exactly-one.ts @@ -0,0 +1,15 @@ +/** + * Forces the consumer to set exactly one of the given keys. + * + * ```ts + * type SimilarOptions = RequireExactlyOne<{ + * conceptId: number; + * conceptName: string; + * query: string; + * }>; + * ``` + */ +export type RequireExactlyOne = Omit & + { + [P in K]: Required> & Partial, undefined>>; + }[K]; diff --git a/src/common/interfaces/vocab-release.ts b/src/common/interfaces/vocab-release.ts new file mode 100644 index 0000000..e6e1525 --- /dev/null +++ b/src/common/interfaces/vocab-release.ts @@ -0,0 +1,8 @@ +export interface VocabReleaseMixin { + /** + * Vocabulary release to query (e.g. `"2025.1"`). Sent as a `?vocab_release=` + * query-string parameter. If the client was constructed with + * `vocabVersion`, this per-call value takes precedence at the API level. + */ + vocabRelease?: string; +} diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts new file mode 100644 index 0000000..3087e36 --- /dev/null +++ b/src/common/utils/backoff.ts @@ -0,0 +1,48 @@ +const INITIAL_DELAY_MS = 500; +const MAX_DELAY_MS = 8_000; +const MAX_RETRY_AFTER_SEC = 60; +const MIN_RETRY_AFTER_MS = 100; + +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + +export function isRetryableStatus(status: number): boolean { + return RETRYABLE_STATUSES.has(status); +} + +/** + * Computes the delay before the next retry attempt. + * + * - Honours `Retry-After` header (seconds or HTTP-date) up to a 60s cap, + * floored to a 100ms minimum so a `Retry-After: 0` doesn't translate to + * an immediate-spam retry. + * - Otherwise full-jitter exponential backoff: + * `min(500 * 2^attempt, 8000) * (1 - 0.25 * random())` + * + * Mirrors the OMOPHub Python SDK's `_calculate_retry_delay`. + */ +export function backoffMs(attempt: number, retryAfter: string | null): number { + if (retryAfter) { + const seconds = parseRetryAfter(retryAfter); + if (seconds !== null && seconds <= MAX_RETRY_AFTER_SEC) { + return Math.max(seconds * 1000, MIN_RETRY_AFTER_MS); + } + } + const exp = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); + return exp * (1 - 0.25 * Math.random()); +} + +/** + * Parses a `Retry-After` header value (delta-seconds or HTTP-date) into + * a number of seconds. Returns `null` for unparseable input. Exported so + * `parse-error.ts` can populate `ErrorResponse.retryAfter` consistently. + */ +export function parseRetryAfter(header: string): number | null { + const seconds = Number(header); + if (Number.isFinite(seconds) && seconds >= 0) return seconds; + const date = Date.parse(header); + if (Number.isFinite(date)) { + const diffMs = date - Date.now(); + return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; + } + return null; +} diff --git a/src/common/utils/build-query.ts b/src/common/utils/build-query.ts new file mode 100644 index 0000000..e1eb8c0 --- /dev/null +++ b/src/common/utils/build-query.ts @@ -0,0 +1,33 @@ +import type { QueryValue } from '../interfaces/per-call-options.js'; +import { camelToSnakeCase } from './to-snake-case.js'; + +/** + * Serialises a record of options into a URL query string. + * - camelCase keys are converted to snake_case. + * - `null` and `undefined` values are dropped. + * - Arrays are comma-joined (matches the OMOPHub API convention). + * - Empty arrays are dropped. + * - Booleans and numbers are stringified. + * + * Returns the encoded query body **without** a leading `?`. + */ +export function buildQuery(params: Record | undefined): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + const snakeKey = camelToSnakeCase(key); + if (Array.isArray(value)) { + if (value.length === 0) continue; + search.append(snakeKey, value.join(',')); + } else { + search.append(snakeKey, String(value)); + } + } + return search.toString(); +} + +export function appendQuery(path: string, query: string): string { + if (!query) return path; + return `${path}${path.includes('?') ? '&' : '?'}${query}`; +} diff --git a/src/common/utils/env.ts b/src/common/utils/env.ts new file mode 100644 index 0000000..9946b1f --- /dev/null +++ b/src/common/utils/env.ts @@ -0,0 +1,12 @@ +/** + * Reads a process.env value, returning undefined if process is not + * available (Cloudflare Workers, Vercel Edge, etc.). Never throws. + */ +export function envOrUndefined(name: string): string | undefined { + if (typeof process === 'undefined' || !process.env) return undefined; + return process.env[name]; +} + +export function envOr(name: string, fallback: string): string { + return envOrUndefined(name) ?? fallback; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts new file mode 100644 index 0000000..7ce44bf --- /dev/null +++ b/src/common/utils/index.ts @@ -0,0 +1,8 @@ +export { backoffMs, isRetryableStatus } from './backoff.js'; +export { appendQuery, buildQuery } from './build-query.js'; +export { envOr, envOrUndefined } from './env.js'; +export { mergeHeaders } from './merge-headers.js'; +export { connectionError, parseErrorResponse, timeoutError } from './parse-error.js'; +export { sleep } from './sleep.js'; +export { camelToSnakeCase, toSnakeCaseKeys } from './to-snake-case.js'; +export { type UnwrappedEnvelope, unwrapEnvelope } from './unwrap-envelope.js'; diff --git a/src/common/utils/merge-headers.ts b/src/common/utils/merge-headers.ts new file mode 100644 index 0000000..a539789 --- /dev/null +++ b/src/common/utils/merge-headers.ts @@ -0,0 +1,15 @@ +import type { HeadersInit } from '../interfaces/per-call-options.js'; + +/** + * Returns a new Headers object with `extra` merged onto `base`. Keys in + * `extra` overwrite keys in `base`. Neither input is mutated. + */ +export function mergeHeaders(base: Headers, extra: HeadersInit | undefined): Headers { + if (!extra) return new Headers(base); + const merged = new Headers(base); + const incoming = new Headers(extra); + incoming.forEach((value, key) => { + merged.set(key, value); + }); + return merged; +} diff --git a/src/common/utils/parse-error.ts b/src/common/utils/parse-error.ts new file mode 100644 index 0000000..0178055 --- /dev/null +++ b/src/common/utils/parse-error.ts @@ -0,0 +1,131 @@ +import type { ErrorResponse, OMOPHUB_ERROR_CODE_KEY } from '../../interfaces.js'; +import { parseRetryAfter } from './backoff.js'; + +const STATUS_TO_CODE: Record = { + 400: 'validation_error', + 401: 'invalid_api_key', + 403: 'restricted_api_key', + 404: 'not_found', + 405: 'method_not_allowed', + 409: 'conflict', + 429: 'rate_limit_exceeded', +}; + +const KNOWN_CODES = new Set([ + 'missing_api_key', + 'invalid_api_key', + 'restricted_api_key', + 'validation_error', + 'missing_required_field', + 'invalid_argument', + 'not_found', + 'method_not_allowed', + 'conflict', + 'rate_limit_exceeded', + 'tier_limit_exceeded', + 'internal_server_error', + 'service_unavailable', + 'connection_error', + 'timeout_error', + 'application_error', +]); + +/** + * Converts a non-OK fetch Response into an ErrorResponse. Parses the JSON + * body if present, maps HTTP status to a stable error code, and pulls + * request-id + retry-after from headers. + */ +export async function parseErrorResponse(response: Response): Promise { + const status = response.status; + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + parsed = null; + } + + const message = extractMessage(parsed) ?? `HTTP ${status}`; + const name = pickErrorCode(status, parsed); + const requestId = + response.headers.get('x-request-id') ?? response.headers.get('X-Request-Id') ?? undefined; + + const error: ErrorResponse = { + name, + message, + statusCode: status, + }; + if (requestId) error.requestId = requestId; + + if (status === 429) { + const retryAfterHeader = response.headers.get('retry-after'); + if (retryAfterHeader) { + const seconds = parseRetryAfter(retryAfterHeader); + if (seconds !== null) error.retryAfter = seconds; + } + } + + const details = extractDetails(parsed); + if (details) error.details = details; + + return error; +} + +export function connectionError(err: unknown): ErrorResponse { + return { + name: 'connection_error', + message: err instanceof Error ? err.message : 'Network error', + statusCode: null, + }; +} + +export function timeoutError(): ErrorResponse { + return { + name: 'timeout_error', + message: 'Request timed out.', + statusCode: null, + }; +} + +function extractMessage(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const b = body as Record; + if (typeof b.message === 'string') return b.message; + if (b.error && typeof b.error === 'object') { + const inner = b.error as Record; + if (typeof inner.message === 'string') return inner.message; + } + if (typeof b.error === 'string') return b.error; + return null; +} + +function extractDetails(body: unknown): Record | undefined { + if (!body || typeof body !== 'object') return undefined; + const b = body as Record; + if (b.error && typeof b.error === 'object') { + const inner = b.error as Record; + if (inner.details && typeof inner.details === 'object') { + return inner.details as Record; + } + } + return undefined; +} + +function pickErrorCode(status: number, body: unknown): OMOPHUB_ERROR_CODE_KEY { + const mapped = STATUS_TO_CODE[status]; + if (mapped) return mapped; + + if (status >= 500 && status < 600) { + return status === 503 ? 'service_unavailable' : 'internal_server_error'; + } + + if (body && typeof body === 'object') { + const b = body as Record; + const inner = + b.error && typeof b.error === 'object' ? (b.error as Record) : null; + const candidate = inner?.code ?? inner?.name; + if (typeof candidate === 'string' && KNOWN_CODES.has(candidate)) { + return candidate as OMOPHUB_ERROR_CODE_KEY; + } + } + return 'application_error'; +} diff --git a/src/common/utils/sleep.ts b/src/common/utils/sleep.ts new file mode 100644 index 0000000..c31b43c --- /dev/null +++ b/src/common/utils/sleep.ts @@ -0,0 +1,21 @@ +/** + * Promise-based sleep. Resolves after `ms` milliseconds. If a signal is + * provided and aborted, rejects with the signal's reason and clears the timer. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/src/common/utils/to-snake-case.ts b/src/common/utils/to-snake-case.ts new file mode 100644 index 0000000..f8b7bcb --- /dev/null +++ b/src/common/utils/to-snake-case.ts @@ -0,0 +1,37 @@ +/** + * Converts a camelCase string to snake_case. Used at the request boundary + * to translate option-object keys into wire-format keys. + * + * Treats consecutive uppercase runs as a single acronym so that + * `FHIRResource` → `fhir_resource` and `XMLHttpRequest` → `xml_http_request` + * rather than splitting between each letter. Trailing uppercase runs are + * also preserved (`userID` → `user_id`). + * + * A single uppercase letter followed by another uppercase is treated as a + * separate word (`OAuthToken` → `o_auth_token`) — matches `lodash.snakeCase`. + */ +export function camelToSnakeCase(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); +} + +/** + * Recursively converts an object's keys from camelCase to snake_case. + * Arrays are mapped element-wise; primitives pass through. Used for + * request bodies only — responses stay snake_case to match the API. + */ +export function toSnakeCaseKeys(input: T): T { + if (Array.isArray(input)) { + return input.map((item) => toSnakeCaseKeys(item)) as unknown as T; + } + if (input !== null && typeof input === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + out[camelToSnakeCase(k)] = toSnakeCaseKeys(v); + } + return out as T; + } + return input; +} diff --git a/src/common/utils/unwrap-envelope.ts b/src/common/utils/unwrap-envelope.ts new file mode 100644 index 0000000..075a567 --- /dev/null +++ b/src/common/utils/unwrap-envelope.ts @@ -0,0 +1,46 @@ +import type { ResponseMeta } from '../../interfaces.js'; + +export interface UnwrappedEnvelope { + data: T; + meta: ResponseMeta | null; +} + +/** + * Tolerantly extracts payload and metadata from a 2xx response body. + * + * The OMOPHub API returns `{ success, data, meta }` for most endpoints + * but a few legacy paths return the data payload directly. To avoid + * mis-classifying a user payload that happens to have a top-level `data` + * field (e.g. a paginated result), the heuristic requires a second + * envelope-specific key — `success` or `meta` — to co-occur with `data`. + * + * - Envelope (`{ data, success }` or `{ data, meta }` or `{ data, success, meta }`): + * unwrap to `body.data` and extract `body.meta`. + * - Anything else: treat the body as the payload (no meta). + * + * Caller-side error envelopes (`success: false`) are not expected here — + * the dispatch loop routes 4xx/5xx through `parseErrorResponse` first. + */ +export function unwrapEnvelope(body: unknown): UnwrappedEnvelope { + if (body && typeof body === 'object' && 'data' in body && ('success' in body || 'meta' in body)) { + const envelope = body as { data: unknown; meta?: unknown }; + return { + data: envelope.data as T, + meta: extractMeta(envelope.meta), + }; + } + return { data: body as T, meta: null }; +} + +function extractMeta(raw: unknown): ResponseMeta | null { + if (!raw || typeof raw !== 'object') return null; + const m = raw as Record; + const meta: ResponseMeta = {}; + if (typeof m.request_id === 'string') meta.request_id = m.request_id; + if (typeof m.timestamp === 'string') meta.timestamp = m.timestamp; + if (typeof m.vocab_release === 'string') meta.vocab_release = m.vocab_release; + if (m.pagination && typeof m.pagination === 'object') { + meta.pagination = m.pagination as ResponseMeta['pagination']; + } + return Object.keys(meta).length > 0 ? meta : null; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..3b4c84b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,30 @@ +import type { OMOPHUB_ERROR_CODE_KEY } from './interfaces.js'; + +/** + * Thrown only on SDK misuse (e.g. missing API key at construction time). + * Network failures and API errors return through the discriminated + * `Response` shape — they are never thrown. + */ +export class OMOPHubError extends Error { + constructor(message: string) { + super(message); + this.name = 'OMOPHubError'; + } +} + +/** + * Thrown from async iterators when a page fetch fails. Async generators + * cannot gracefully yield a discriminated `{ data, error }` union, so + * iterators throw and `*All` variants accumulate. + */ +export class OMOPHubIteratorError extends Error { + readonly statusCode: number | null; + readonly code: OMOPHUB_ERROR_CODE_KEY; + + constructor(message: string, statusCode: number | null, code: OMOPHUB_ERROR_CODE_KEY) { + super(message); + this.name = 'OMOPHubIteratorError'; + this.statusCode = statusCode; + this.code = code; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..68773bd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,34 @@ +export { OMOPHub, type OMOPHubOptions } from './client.js'; +export type { + ApiEnvelope, + ApiErrorEnvelope, + ApiSuccessEnvelope, + DeleteOptions, + GetOptions, + IdempotentRequest, + PaginatedData, + PaginationMeta, + PaginationOptions, + PatchOptions, + PerCallOptions, + PostOptions, + PutOptions, + QueryValue, + RequireAtLeastOne, + RequireExactlyOne, + VocabReleaseMixin, +} from './common/interfaces/index.js'; +export { OMOPHubError, OMOPHubIteratorError } from './errors.js'; +export type { + ErrorResponse, + OMOPHUB_ERROR_CODE_KEY, + Response, + ResponseMeta, +} from './interfaces.js'; +export { __version__ } from './version.js'; +export type { + ListVocabulariesOptions, + Vocabulary, + VocabularyStats, + VocabularySummary, +} from './vocabularies/interfaces/index.js'; diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..c501d6a --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,64 @@ +import type { PaginationMeta } from './common/interfaces/pagination.js'; + +/** + * Canonical SDK error codes. Stable across releases — safe to switch on. + */ +export type OMOPHUB_ERROR_CODE_KEY = + // Auth + | 'missing_api_key' + | 'invalid_api_key' + | 'restricted_api_key' + // Request shape + | 'validation_error' + | 'missing_required_field' + | 'invalid_argument' + // Resource + | 'not_found' + | 'method_not_allowed' + | 'conflict' + // Quota + | 'rate_limit_exceeded' + | 'tier_limit_exceeded' + // Server + | 'internal_server_error' + | 'service_unavailable' + // Transport + | 'connection_error' + | 'timeout_error' + // Catch-all + | 'application_error'; + +export interface ErrorResponse { + name: OMOPHUB_ERROR_CODE_KEY; + message: string; + statusCode: number | null; + requestId?: string; + retryAfter?: number; + details?: Record; +} + +export interface ResponseMeta { + request_id?: string; + timestamp?: string; + vocab_release?: string; + pagination?: PaginationMeta; +} + +/** + * Discriminated-union return type for every SDK method. + * + * On success: `{ data: T, error: null, meta, headers }` — `data` is the + * unwrapped payload from the API envelope, `meta` carries pagination / + * request_id / vocab_release when present. + * + * On error: `{ data: null, error: ErrorResponse, meta: null, headers }` — + * `headers` is preserved when the error came from the wire, null when + * the error was synthesised client-side. + * + * Narrow with `if (error) ...` — TypeScript will type `data` as `T` in the + * `else` branch. + */ +export type Response = ( + | { data: T; error: null; meta: ResponseMeta | null } + | { data: null; error: ErrorResponse; meta: null } +) & { headers: Record | null }; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..112dba0 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +/** + * SDK version. Bumped in lockstep with package.json. Surfaced in the + * User-Agent header so server-side logs can attribute traffic to a release. + */ +export const __version__ = '0.0.1'; diff --git a/src/vocabularies/interfaces/index.ts b/src/vocabularies/interfaces/index.ts new file mode 100644 index 0000000..591de49 --- /dev/null +++ b/src/vocabularies/interfaces/index.ts @@ -0,0 +1,2 @@ +export type { ListVocabulariesOptions } from './list-vocabularies-options.js'; +export type { Vocabulary, VocabularyStats, VocabularySummary } from './vocabulary.js'; diff --git a/src/vocabularies/interfaces/list-vocabularies-options.ts b/src/vocabularies/interfaces/list-vocabularies-options.ts new file mode 100644 index 0000000..d2e0dd6 --- /dev/null +++ b/src/vocabularies/interfaces/list-vocabularies-options.ts @@ -0,0 +1,8 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface ListVocabulariesOptions extends PaginationOptions { + includeStats?: boolean; + includeInactive?: boolean; + sortBy?: 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/vocabularies/interfaces/vocabulary.ts b/src/vocabularies/interfaces/vocabulary.ts new file mode 100644 index 0000000..3e87ffa --- /dev/null +++ b/src/vocabularies/interfaces/vocabulary.ts @@ -0,0 +1,28 @@ +/** + * OMOP vocabulary metadata. Snake_case keys match the wire payload — + * no translation applied to responses. + */ +export interface Vocabulary { + vocabulary_id: string; + vocabulary_name: string; + vocabulary_concept_id?: number; + vocabulary_reference?: string; + vocabulary_version?: string; +} + +export interface VocabularyStats { + vocabulary_id: string; + vocabulary_name: string; + total_concepts: number; + standard_concepts?: number; + classification_concepts?: number; + invalid_concepts?: number; + active_concepts?: number; + valid_start_date?: string; + valid_end_date?: string; + last_updated?: string; +} + +export interface VocabularySummary extends Vocabulary { + stats?: VocabularyStats; +} diff --git a/src/vocabularies/vocabularies.ts b/src/vocabularies/vocabularies.ts new file mode 100644 index 0000000..96a71a9 --- /dev/null +++ b/src/vocabularies/vocabularies.ts @@ -0,0 +1,37 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PaginatedData } from '../common/interfaces/pagination.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { ListVocabulariesOptions } from './interfaces/list-vocabularies-options.js'; +import type { Vocabulary } from './interfaces/vocabulary.js'; + +export class Vocabularies { + constructor(private readonly client: OMOPHub) {} + + /** + * List vocabularies. + * + * ```ts + * const { data, error } = await client.vocabularies.list({ pageSize: 50 }); + * ``` + * + * @see https://docs.omophub.com/api-reference/vocabularies/list + */ + async list( + options: ListVocabulariesOptions = {}, + requestOptions: GetOptions = {}, + ): Promise>> { + return this.client.get>('/vocabularies', { + ...requestOptions, + query: { + page: options.page, + page_size: options.pageSize, + include_stats: options.includeStats, + include_inactive: options.includeInactive, + sort_by: options.sortBy, + sort_order: options.sortOrder, + ...requestOptions.query, + }, + }); + } +} diff --git a/test/client-http.test.ts b/test/client-http.test.ts new file mode 100644 index 0000000..5e79335 --- /dev/null +++ b/test/client-http.test.ts @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { OMOPHub } from '../src/client.js'; +import { mockApiEnvelope, mockApiErrorBody, mockVocabulary } from './fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueNetworkError, + enqueueSuccess, + lastCall, +} from './helpers/mock-fetch.js'; + +describe('OMOPHub HTTP dispatch', () => { + let originalRandom: typeof Math.random; + let originalKey: string | undefined; + + beforeEach(() => { + originalRandom = Math.random; + Math.random = () => 0; + originalKey = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + Math.random = originalRandom; + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + vi.useRealTimers(); + }); + + test('GET sends the right URL, method, and headers', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockVocabulary()]); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error, headers } = await client.get('/vocabularies'); + + expect(error).toBeNull(); + expect(data).toEqual([mockVocabulary()]); + expect(headers).not.toBeNull(); + + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/vocabularies'); + expect(init.method).toBe('GET'); + const requestHeaders = new Headers(init.headers); + expect(requestHeaders.get('authorization')).toBe('Bearer oh_test'); + expect(requestHeaders.get('user-agent')).toMatch(/^omophub-node\//); + expect(requestHeaders.get('content-type')).toBe('application/json'); + expect(init.body).toBeUndefined(); + }); + + test('GET serialises query params, converting camelCase to snake_case', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, []); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/vocabularies', { + query: { page: 2, pageSize: 50, vocabularyIds: ['SNOMED', 'ICD10'] }, + }); + + const { url } = lastCall(fetchMock); + expect(url).toBe( + 'https://api.omophub.com/v1/vocabularies?page=2&page_size=50&vocabulary_ids=SNOMED%2CICD10', + ); + }); + + test('POST sends JSON body and sets Idempotency-Key when provided', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { result: 'ok' }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.post('/concepts/batch', { concept_ids: [1, 2, 3] }, { idempotencyKey: 'idem_1' }); + + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ concept_ids: [1, 2, 3] })); + expect(new Headers(init.headers).get('idempotency-key')).toBe('idem_1'); + }); + + test('POST does NOT set Idempotency-Key when not provided', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.post('/concepts/batch', { x: 1 }); + + const { init } = lastCall(fetchMock); + expect(new Headers(init.headers).has('idempotency-key')).toBe(false); + }); + + test('returns ErrorResponse on 4xx without retrying', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error, headers } = await client.get('/concepts/1'); + + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + expect(error?.message).toBe('concept missing'); + expect(error?.statusCode).toBe(404); + expect(headers).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('retries on 429 honouring Retry-After', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 429, mockApiErrorBody('rate_limit_exceeded', 'slow'), { + 'retry-after': '0', + }); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('retries on 503 with exponential backoff and eventually succeeds', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('gives up after maxRetries on persistent 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 2 }); + const { data, error } = await client.get('/foo'); + + expect(data).toBeNull(); + expect(error?.name).toBe('service_unavailable'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('retries on network error and then succeeds', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 2 }); + const { error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('returns connection_error after exhausting retries on network failure', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock, new Error('ECONNREFUSED')); + enqueueNetworkError(fetchMock, new Error('ECONNREFUSED')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 1 }); + const { data, error, headers } = await client.get('/foo'); + + expect(data).toBeNull(); + expect(error?.name).toBe('connection_error'); + expect(error?.message).toBe('ECONNREFUSED'); + expect(headers).toBeNull(); + }); + + test('does NOT retry POST without an Idempotency-Key on 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(data).toBeNull(); + expect(error?.name).toBe('service_unavailable'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('retries POST WITH an Idempotency-Key on 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post( + '/concepts/batch', + { concept_ids: [1] }, + { idempotencyKey: 'idem_42' }, + ); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does NOT retry POST without an Idempotency-Key on network error', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(data).toBeNull(); + expect(error?.name).toBe('connection_error'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('attaches X-Vocab-Version header when vocabVersion is set', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, vocabVersion: '2025.1' }); + await client.get('/concepts/201826'); + + const { init } = lastCall(fetchMock); + expect(new Headers(init.headers).get('x-vocab-version')).toBe('2025.1'); + }); + + test('per-call headers merge onto client headers', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/concepts/1', { headers: { 'X-Custom': 'yes' } }); + + const { init } = lastCall(fetchMock); + const requestHeaders = new Headers(init.headers); + expect(requestHeaders.get('x-custom')).toBe('yes'); + expect(requestHeaders.get('authorization')).toBe('Bearer oh_test'); + }); + + test('caller AbortSignal propagates as a thrown AbortError, not a return value', async () => { + const fetchMock = createMockFetch(); + fetchMock.mockImplementation((_url, init) => { + const signal = (init as RequestInit | undefined)?.signal; + return new Promise((_resolve, reject) => { + if (signal) { + signal.addEventListener('abort', () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + reject(abortErr); + }); + } + }); + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0, timeoutMs: 0 }); + const controller = new AbortController(); + const pending = client.get('/slow', { signal: controller.signal }); + controller.abort(); + + await expect(pending).rejects.toThrow(/aborted/); + }); + + test('timeout aborts return timeout_error', async () => { + // Pure fake timers (overrides the shouldAdvanceTime config from beforeEach) + // so the timeout fires deterministically rather than depending on wall-clock. + vi.useFakeTimers({ shouldAdvanceTime: false }); + const fetchMock = createMockFetch(); + fetchMock.mockImplementation((_url, init) => { + const signal = (init as RequestInit | undefined)?.signal; + return new Promise((_resolve, reject) => { + if (signal) { + signal.addEventListener('abort', () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + reject(abortErr); + }); + } + }); + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0, timeoutMs: 10 }); + const pending = client.get('/slow'); + await vi.advanceTimersByTimeAsync(20); + const { data, error } = await pending; + + expect(data).toBeNull(); + expect(error?.name).toBe('timeout_error'); + }); + + test('reads OMOPHUB_API_URL env var for baseUrl', async () => { + const originalUrl = process.env.OMOPHUB_API_URL; + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + try { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, []); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/vocabularies'); + expect(lastCall(fetchMock).url).toBe('https://staging.omophub.com/v1/vocabularies'); + } finally { + if (originalUrl !== undefined) process.env.OMOPHUB_API_URL = originalUrl; + else delete process.env.OMOPHUB_API_URL; + } + }); + + test('returns headers as a plain Record on every code path', async () => { + const fetchMock = createMockFetch(); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockApiEnvelope({ ok: true })), { + status: 200, + headers: { 'content-type': 'application/json', 'x-request-id': 'req_99' }, + }), + ); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { headers } = await client.get('/foo'); + expect(headers?.['x-request-id']).toBe('req_99'); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..6b9bff8 --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { OMOPHub } from '../src/client.js'; +import { createMockFetch, enqueueSuccess, lastCall } from './helpers/mock-fetch.js'; + +describe('OMOPHub constructor', () => { + let originalKey: string | undefined; + let originalUrl: string | undefined; + + beforeEach(() => { + originalKey = process.env.OMOPHUB_API_KEY; + originalUrl = process.env.OMOPHUB_API_URL; + delete process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_URL; + }); + + afterEach(() => { + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + if (originalUrl !== undefined) process.env.OMOPHUB_API_URL = originalUrl; + }); + + test('accepts an API key argument and defaults other options', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub('oh_test_key', { fetch: fetchMock }); + await client.get('/anything'); + + expect(client.baseUrl).toBe('https://api.omophub.com/v1'); + expect(client.timeoutMs).toBe(30_000); + expect(client.maxRetries).toBe(3); + expect(client.userAgent).toMatch(/^omophub-node\/\d+\.\d+\.\d+/); + expect(client.vocabVersion).toBeUndefined(); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_test_key', + ); + }); + + test('throws if no API key is provided and OMOPHUB_API_KEY is unset', () => { + expect(() => new OMOPHub()).toThrowError(/Missing API key/); + }); + + test('reads OMOPHUB_API_KEY from env when no argument is provided', async () => { + process.env.OMOPHUB_API_KEY = 'oh_env_key'; + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub(undefined, { fetch: fetchMock }); + await client.get('/anything'); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_env_key', + ); + }); + + test('argument takes precedence over OMOPHUB_API_KEY env', async () => { + process.env.OMOPHUB_API_KEY = 'oh_env_key'; + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub('oh_arg_key', { fetch: fetchMock }); + await client.get('/anything'); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_arg_key', + ); + }); + + test('reads OMOPHUB_API_URL from env when no baseUrl option is provided', () => { + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + const client = new OMOPHub('oh_test'); + expect(client.baseUrl).toBe('https://staging.omophub.com/v1'); + }); + + test('baseUrl option takes precedence over OMOPHUB_API_URL env', () => { + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + const client = new OMOPHub('oh_test', { baseUrl: 'https://custom.example.com' }); + expect(client.baseUrl).toBe('https://custom.example.com'); + }); + + test('accepts overrides for timeoutMs, maxRetries, userAgent, vocabVersion', () => { + const client = new OMOPHub('oh_test', { + timeoutMs: 5_000, + maxRetries: 0, + userAgent: 'my-app/1.0.0', + vocabVersion: '2025.1', + }); + expect(client.timeoutMs).toBe(5_000); + expect(client.maxRetries).toBe(0); + expect(client.userAgent).toBe('my-app/1.0.0'); + expect(client.vocabVersion).toBe('2025.1'); + }); + + test('does not expose the API key as a public field', () => { + const client = new OMOPHub('oh_secret'); + expect((client as unknown as Record).apiKey).toBeUndefined(); + expect(Object.keys(client)).not.toContain('apiKey'); + }); +}); diff --git a/test/common/utils/backoff.test.ts b/test/common/utils/backoff.test.ts new file mode 100644 index 0000000..877b3af --- /dev/null +++ b/test/common/utils/backoff.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { backoffMs, isRetryableStatus } from '../../../src/common/utils/backoff.js'; + +describe('isRetryableStatus', () => { + test('retries 429 and 5xx-ish statuses', () => { + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(502)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(504)).toBe(true); + }); + + test('does not retry 4xx (except 429) or 2xx', () => { + expect(isRetryableStatus(200)).toBe(false); + expect(isRetryableStatus(400)).toBe(false); + expect(isRetryableStatus(401)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(500)).toBe(false); + }); +}); + +describe('backoffMs', () => { + beforeEach(() => { + vi.spyOn(Math, 'random').mockReturnValue(0); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('honours Retry-After in seconds when within 60s cap', () => { + expect(backoffMs(0, '2')).toBe(2000); + expect(backoffMs(5, '30')).toBe(30_000); + }); + + test('ignores Retry-After above 60s and falls back to exponential', () => { + // 0.25 jitter mocked to 0 → no scaling + expect(backoffMs(0, '120')).toBe(500); + }); + + test('exponential backoff doubles per attempt, capped at 8s', () => { + expect(backoffMs(0, null)).toBe(500); + expect(backoffMs(1, null)).toBe(1000); + expect(backoffMs(2, null)).toBe(2000); + expect(backoffMs(3, null)).toBe(4000); + expect(backoffMs(4, null)).toBe(8000); + expect(backoffMs(5, null)).toBe(8000); + expect(backoffMs(10, null)).toBe(8000); + }); + + test('jitter scales down by up to 25%', () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + expect(backoffMs(0, null)).toBeCloseTo(500 * 0.75, 5); + }); + + test('falls back to exponential for unparseable Retry-After', () => { + expect(backoffMs(0, 'garbage')).toBe(500); + }); + + test('handles HTTP-date Retry-After values', () => { + const fiveSecondsFromNow = new Date(Date.now() + 5_000).toUTCString(); + const result = backoffMs(0, fiveSecondsFromNow); + expect(result).toBeGreaterThanOrEqual(4_000); + expect(result).toBeLessThanOrEqual(6_000); + }); +}); diff --git a/test/common/utils/build-query.test.ts b/test/common/utils/build-query.test.ts new file mode 100644 index 0000000..b4ee2c3 --- /dev/null +++ b/test/common/utils/build-query.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; +import { appendQuery, buildQuery } from '../../../src/common/utils/build-query.js'; + +describe('buildQuery', () => { + test('returns empty string for undefined or empty input', () => { + expect(buildQuery(undefined)).toBe(''); + expect(buildQuery({})).toBe(''); + }); + + test('converts camelCase keys to snake_case', () => { + expect(buildQuery({ pageSize: 50 })).toBe('page_size=50'); + expect(buildQuery({ includeRelationships: true })).toBe('include_relationships=true'); + }); + + test('drops null and undefined values', () => { + expect(buildQuery({ page: 1, foo: null, bar: undefined })).toBe('page=1'); + }); + + test('joins arrays with commas', () => { + expect(buildQuery({ vocabularyIds: ['SNOMED', 'ICD10'] })).toBe( + 'vocabulary_ids=SNOMED%2CICD10', + ); + }); + + test('drops empty arrays', () => { + expect(buildQuery({ page: 1, vocabularyIds: [] })).toBe('page=1'); + }); + + test('stringifies booleans and numbers', () => { + expect(buildQuery({ page: 1, includeStats: false })).toBe('page=1&include_stats=false'); + }); + + test('preserves multiple keys', () => { + expect(buildQuery({ page: 1, pageSize: 50, includeStats: true })).toBe( + 'page=1&page_size=50&include_stats=true', + ); + }); +}); + +describe('appendQuery', () => { + test('returns the path unchanged when query is empty', () => { + expect(appendQuery('/concepts', '')).toBe('/concepts'); + }); + + test('appends with ? when path has no query', () => { + expect(appendQuery('/concepts', 'page=1')).toBe('/concepts?page=1'); + }); + + test('appends with & when path already has a query', () => { + expect(appendQuery('/concepts?foo=bar', 'page=1')).toBe('/concepts?foo=bar&page=1'); + }); +}); diff --git a/test/common/utils/parse-error.test.ts b/test/common/utils/parse-error.test.ts new file mode 100644 index 0000000..85747dc --- /dev/null +++ b/test/common/utils/parse-error.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'vitest'; +import { + connectionError, + parseErrorResponse, + timeoutError, +} from '../../../src/common/utils/parse-error.js'; + +function makeResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +describe('parseErrorResponse', () => { + test('maps 400 to validation_error', async () => { + const err = await parseErrorResponse( + makeResponse(400, { success: false, error: { message: 'bad input' } }), + ); + expect(err.name).toBe('validation_error'); + expect(err.message).toBe('bad input'); + expect(err.statusCode).toBe(400); + }); + + test('maps 401 to invalid_api_key', async () => { + const err = await parseErrorResponse( + makeResponse(401, { success: false, error: { message: 'bad key' } }), + ); + expect(err.name).toBe('invalid_api_key'); + }); + + test('maps 403 to restricted_api_key', async () => { + const err = await parseErrorResponse(makeResponse(403, { message: 'forbidden' })); + expect(err.name).toBe('restricted_api_key'); + }); + + test('maps 404 to not_found', async () => { + const err = await parseErrorResponse(makeResponse(404, { message: 'gone' })); + expect(err.name).toBe('not_found'); + }); + + test('maps 429 to rate_limit_exceeded and parses Retry-After', async () => { + const err = await parseErrorResponse( + makeResponse(429, { message: 'slow down' }, { 'retry-after': '15' }), + ); + expect(err.name).toBe('rate_limit_exceeded'); + expect(err.retryAfter).toBe(15); + }); + + test('maps 503 to service_unavailable', async () => { + const err = await parseErrorResponse(makeResponse(503, { message: 'down' })); + expect(err.name).toBe('service_unavailable'); + }); + + test('maps other 5xx to internal_server_error', async () => { + const err = await parseErrorResponse(makeResponse(500, { message: 'boom' })); + expect(err.name).toBe('internal_server_error'); + }); + + test('falls back to application_error for unmapped statuses', async () => { + const err = await parseErrorResponse(makeResponse(418, { message: 'teapot' })); + expect(err.name).toBe('application_error'); + }); + + test('captures x-request-id header', async () => { + const err = await parseErrorResponse( + makeResponse(400, { message: 'bad' }, { 'x-request-id': 'req_xyz' }), + ); + expect(err.requestId).toBe('req_xyz'); + }); + + test('captures details from error envelope', async () => { + const err = await parseErrorResponse( + makeResponse(400, { + success: false, + error: { message: 'bad', details: { field: 'concept_id' } }, + }), + ); + expect(err.details).toEqual({ field: 'concept_id' }); + }); + + test('handles non-JSON body gracefully', async () => { + const response = new Response('not json', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + const err = await parseErrorResponse(response); + expect(err.name).toBe('internal_server_error'); + expect(err.message).toBe('HTTP 500'); + }); + + test('respects known error code from server when status is unmapped', async () => { + const err = await parseErrorResponse( + makeResponse(418, { + success: false, + error: { code: 'tier_limit_exceeded', message: 'upgrade' }, + }), + ); + expect(err.name).toBe('tier_limit_exceeded'); + }); +}); + +describe('connectionError', () => { + test('uses the Error message when available', () => { + const err = connectionError(new Error('socket hang up')); + expect(err.name).toBe('connection_error'); + expect(err.message).toBe('socket hang up'); + expect(err.statusCode).toBeNull(); + }); + + test('falls back to a default message for non-Error throws', () => { + const err = connectionError('weird'); + expect(err.message).toBe('Network error'); + }); +}); + +describe('timeoutError', () => { + test('returns a timeout_error with null statusCode', () => { + const err = timeoutError(); + expect(err.name).toBe('timeout_error'); + expect(err.statusCode).toBeNull(); + }); +}); diff --git a/test/common/utils/to-snake-case.test.ts b/test/common/utils/to-snake-case.test.ts new file mode 100644 index 0000000..e280a98 --- /dev/null +++ b/test/common/utils/to-snake-case.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; +import { camelToSnakeCase, toSnakeCaseKeys } from '../../../src/common/utils/to-snake-case.js'; + +describe('camelToSnakeCase', () => { + test('converts camelCase to snake_case', () => { + expect(camelToSnakeCase('conceptId')).toBe('concept_id'); + expect(camelToSnakeCase('vocabularyIds')).toBe('vocabulary_ids'); + expect(camelToSnakeCase('includeRelationships')).toBe('include_relationships'); + }); + + test('leaves snake_case unchanged', () => { + expect(camelToSnakeCase('already_snake')).toBe('already_snake'); + }); + + test('handles single-word identifiers', () => { + expect(camelToSnakeCase('page')).toBe('page'); + }); + + test('treats consecutive uppercase as a single acronym', () => { + expect(camelToSnakeCase('FHIRResource')).toBe('fhir_resource'); + expect(camelToSnakeCase('XMLHttpRequest')).toBe('xml_http_request'); + }); + + test('preserves trailing uppercase runs', () => { + expect(camelToSnakeCase('userID')).toBe('user_id'); + expect(camelToSnakeCase('exportPDF')).toBe('export_pdf'); + }); + + test('splits a lone leading uppercase from the next word (lodash-compatible)', () => { + expect(camelToSnakeCase('OAuthToken')).toBe('o_auth_token'); + }); +}); + +describe('toSnakeCaseKeys', () => { + test('converts object keys recursively', () => { + const input = { + conceptId: 1, + includeRelationships: true, + nested: { sourceCodes: ['SNOMED:123'] }, + }; + expect(toSnakeCaseKeys(input)).toEqual({ + concept_id: 1, + include_relationships: true, + nested: { source_codes: ['SNOMED:123'] }, + }); + }); + + test('passes primitives through', () => { + expect(toSnakeCaseKeys(42)).toBe(42); + expect(toSnakeCaseKeys('hi')).toBe('hi'); + expect(toSnakeCaseKeys(null)).toBeNull(); + expect(toSnakeCaseKeys(undefined)).toBeUndefined(); + }); + + test('walks arrays of objects', () => { + const input = [{ conceptId: 1 }, { conceptId: 2 }]; + expect(toSnakeCaseKeys(input)).toEqual([{ concept_id: 1 }, { concept_id: 2 }]); + }); +}); diff --git a/test/common/utils/unwrap-envelope.test.ts b/test/common/utils/unwrap-envelope.test.ts new file mode 100644 index 0000000..e9cc1f3 --- /dev/null +++ b/test/common/utils/unwrap-envelope.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; +import { unwrapEnvelope } from '../../../src/common/utils/unwrap-envelope.js'; + +describe('unwrapEnvelope', () => { + test('extracts data and meta from a standard envelope', () => { + const body = { + success: true, + data: { vocabulary_id: 'SNOMED' }, + meta: { request_id: 'req_1', timestamp: '2026-05-29T00:00:00Z', vocab_release: '2025.1' }, + }; + const { data, meta } = unwrapEnvelope(body); + expect(data).toEqual({ vocabulary_id: 'SNOMED' }); + expect(meta).toEqual({ + request_id: 'req_1', + timestamp: '2026-05-29T00:00:00Z', + vocab_release: '2025.1', + }); + }); + + test('extracts pagination from meta', () => { + const body = { + data: [{ id: 1 }], + meta: { + pagination: { page: 1, page_size: 20, total_items: 1, total_pages: 1, has_next: false }, + }, + }; + const { data, meta } = unwrapEnvelope(body); + expect(data).toEqual([{ id: 1 }]); + expect(meta?.pagination).toEqual({ + page: 1, + page_size: 20, + total_items: 1, + total_pages: 1, + has_next: false, + }); + }); + + test('returns null meta when envelope has success but no meta', () => { + expect(unwrapEnvelope({ success: true, data: 42 })).toEqual({ data: 42, meta: null }); + }); + + test('treats raw payload as data when no `data` key is present', () => { + expect(unwrapEnvelope([1, 2, 3])).toEqual({ data: [1, 2, 3], meta: null }); + expect(unwrapEnvelope('plain')).toEqual({ data: 'plain', meta: null }); + }); + + test('does NOT unwrap a payload that happens to have a `data` key but no envelope sibling', () => { + // A user-shaped payload like `{ data: [...] }` with no success/meta is + // returned as-is so we never accidentally peel a layer the API didn't add. + const userPayload = { data: [{ concept_id: 1 }] }; + expect(unwrapEnvelope(userPayload)).toEqual({ data: userPayload, meta: null }); + }); + + test('handles null body', () => { + expect(unwrapEnvelope(null)).toEqual({ data: null, meta: null }); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..5144c39 --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHubError, OMOPHubIteratorError } from '../src/errors.js'; + +describe('OMOPHubError', () => { + test('is an Error subclass with the right name', () => { + const err = new OMOPHubError('boom'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('OMOPHubError'); + expect(err.message).toBe('boom'); + }); +}); + +describe('OMOPHubIteratorError', () => { + test('carries statusCode and code fields', () => { + const err = new OMOPHubIteratorError('rate limited', 429, 'rate_limit_exceeded'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('OMOPHubIteratorError'); + expect(err.statusCode).toBe(429); + expect(err.code).toBe('rate_limit_exceeded'); + }); + + test('accepts a null statusCode for client-side failures', () => { + const err = new OMOPHubIteratorError('lost connection', null, 'connection_error'); + expect(err.statusCode).toBeNull(); + expect(err.code).toBe('connection_error'); + }); +}); diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts new file mode 100644 index 0000000..769f024 --- /dev/null +++ b/test/fixtures/index.ts @@ -0,0 +1,46 @@ +import type { PaginationMeta } from '../../src/common/interfaces/pagination.js'; +import type { Vocabulary } from '../../src/vocabularies/interfaces/vocabulary.js'; + +export const DIABETES_CONCEPT_ID = 201826; +export const ASPIRIN_CONCEPT_ID = 1112807; +export const HYPERTENSION_CONCEPT_ID = 316866; +export const COVID_CONCEPT_ID = 37311061; +export const MOCK_API_KEY = 'oh_test_key_12345'; + +export const mockVocabulary = (overrides: Partial = {}): Vocabulary => ({ + vocabulary_id: 'SNOMED', + vocabulary_name: 'SNOMED CT', + vocabulary_concept_id: 44819097, + vocabulary_reference: 'SNOMED CT International Edition', + vocabulary_version: '2024-09-01', + ...overrides, +}); + +export const mockPagination = (overrides: Partial = {}): PaginationMeta => ({ + page: 1, + page_size: 20, + total_items: 1, + total_pages: 1, + has_next: false, + has_previous: false, + ...overrides, +}); + +export const mockApiEnvelope = (data: T, meta: Record = {}) => ({ + success: true as const, + data, + meta: { + request_id: 'req_test_12345', + timestamp: '2026-05-29T00:00:00Z', + ...meta, + }, +}); + +export const mockApiErrorBody = ( + code: string, + message: string, + details?: Record, +) => ({ + success: false as const, + error: details ? { code, message, details } : { code, message }, +}); diff --git a/test/helpers/mock-fetch.ts b/test/helpers/mock-fetch.ts new file mode 100644 index 0000000..83eddd6 --- /dev/null +++ b/test/helpers/mock-fetch.ts @@ -0,0 +1,73 @@ +import { type Mock, vi } from 'vitest'; +import { mockApiEnvelope } from '../fixtures/index.js'; + +export type MockFetch = Mock; + +export function createMockFetch(): MockFetch { + return vi.fn() as MockFetch; +} + +export function enqueueSuccess( + fetchMock: MockFetch, + data: T, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockApiEnvelope(data)), { + status: 200, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueRawBody( + fetchMock: MockFetch, + body: unknown, + status = 200, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueError( + fetchMock: MockFetch, + status: number, + body: unknown = { + success: false, + error: { code: 'application_error', message: `HTTP ${status}` }, + }, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueNetworkError( + fetchMock: MockFetch, + err: Error = new Error('ECONNREFUSED'), +): void { + fetchMock.mockRejectedValueOnce(err); +} + +export function lastCall(fetchMock: MockFetch): { url: string; init: RequestInit } { + const calls = fetchMock.mock.calls; + if (calls.length === 0) throw new Error('fetchMock has no calls'); + const call = calls[calls.length - 1]; + if (!call) throw new Error('fetchMock has no calls'); + const [arg0, arg1] = call; + const url = typeof arg0 === 'string' ? arg0 : (arg0 as URL).toString(); + return { url, init: (arg1 ?? {}) as RequestInit }; +} + +export function headersFromInit(init: RequestInit): Headers { + return new Headers(init.headers); +} diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..a9cf587 --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from 'vitest'; +import { __version__ } from '../src/version.js'; + +describe('__version__', () => { + test('is a semver-like string', () => { + expect(__version__).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/test/vocabularies/vocabularies.test.ts b/test/vocabularies/vocabularies.test.ts new file mode 100644 index 0000000..c9bece2 --- /dev/null +++ b/test/vocabularies/vocabularies.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { mockApiErrorBody, mockPagination, mockVocabulary } from '../fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueRawBody, + enqueueSuccess, + lastCall, +} from '../helpers/mock-fetch.js'; + +describe('client.vocabularies.list', () => { + test('hits GET /vocabularies with no params by default', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [mockVocabulary()], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.list(); + + expect(error).toBeNull(); + expect(data?.data).toEqual([mockVocabulary()]); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies'); + }); + + test('serialises options as snake_case query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.list({ + page: 2, + pageSize: 50, + includeStats: true, + includeInactive: false, + sortBy: 'name', + sortOrder: 'desc', + }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + expect(url).toContain('include_stats=true'); + expect(url).toContain('include_inactive=false'); + expect(url).toContain('sort_by=name'); + expect(url).toContain('sort_order=desc'); + }); + + test('exposes pagination metadata under data.meta.pagination', async () => { + const fetchMock = createMockFetch(); + const pagination = mockPagination({ + page: 3, + page_size: 10, + total_items: 42, + total_pages: 5, + has_next: true, + has_previous: true, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { data: [mockVocabulary()], meta: { pagination } }, + meta: { request_id: 'req_paginated' }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, meta } = await client.vocabularies.list({ page: 3, pageSize: 10 }); + + expect(data?.meta.pagination).toEqual(pagination); + expect(meta?.request_id).toBe('req_paginated'); + }); + + test('returns ErrorResponse on 401', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 401, mockApiErrorBody('invalid_api_key', 'bad key')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.vocabularies.list(); + + expect(data).toBeNull(); + expect(error?.name).toBe('invalid_api_key'); + expect(error?.statusCode).toBe(401); + }); + + test('per-call query overrides spread onto the request', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.list( + { pageSize: 10 }, + { query: { vocab_release: '2025.1', trace: true } }, + ); + + const { url } = lastCall(fetchMock); + expect(url).toContain('page_size=10'); + expect(url).toContain('vocab_release=2025.1'); + expect(url).toContain('trace=true'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0878ac6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..272814f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: { statements: 90 }, + }, + }, +}); From 17138cfbf4dd98363c7de9ecf62606d51da35c1d Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 30 May 2026 08:53:19 +0100 Subject: [PATCH 02/16] Enhance OMOPHub SDK with new concepts and domains resources, update Node.js version requirement, and improve error handling. Introduced methods for concepts and domains, updated changelog, and refined query building logic. Added synthetic error handling for client-side validation and improved retry logic for API requests. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 8 + README.md | 6 +- package.json | 2 +- src/client.ts | 6 + src/common/utils/backoff.ts | 17 +- src/common/utils/build-query.ts | 8 +- src/common/utils/index.ts | 1 + src/common/utils/parse-error.ts | 30 +- src/common/utils/synthetic-error.ts | 21 ++ src/common/utils/to-snake-case.ts | 19 +- src/concepts/concepts.ts | 205 +++++++++++++ .../interfaces/batch-concepts-options.ts | 11 + .../concept-relationships-options.ts | 17 ++ src/concepts/interfaces/concept.ts | 105 +++++++ .../interfaces/get-concept-by-code-options.ts | 7 + .../interfaces/get-concept-options.ts | 7 + src/concepts/interfaces/index.ts | 21 ++ .../recommended-concepts-options.ts | 15 + .../interfaces/related-concepts-options.ts | 9 + .../interfaces/suggest-concepts-options.ts | 12 + src/domains/domains.ts | 47 +++ .../interfaces/domain-concepts-options.ts | 8 + src/domains/interfaces/domain.ts | 22 ++ src/domains/interfaces/index.ts | 3 + .../interfaces/list-domains-options.ts | 4 + src/index.ts | 31 ++ src/vocabularies/interfaces/index.ts | 9 +- .../interfaces/vocabulary-concepts-options.ts | 13 + src/vocabularies/interfaces/vocabulary.ts | 20 ++ src/vocabularies/vocabularies.ts | 102 +++++-- test/common/utils/backoff.test.ts | 10 +- test/common/utils/build-query.test.ts | 6 + test/common/utils/parse-error.test.ts | 18 ++ test/common/utils/synthetic-error.test.ts | 22 ++ test/common/utils/to-snake-case.test.ts | 29 ++ test/concepts/concepts.test.ts | 280 ++++++++++++++++++ test/domains/domains.test.ts | 65 ++++ test/fixtures/index.ts | 22 ++ test/helpers/mock-fetch.ts | 5 +- test/vocabularies/vocabularies.test.ts | 102 ++++++- 41 files changed, 1286 insertions(+), 61 deletions(-) create mode 100644 src/common/utils/synthetic-error.ts create mode 100644 src/concepts/concepts.ts create mode 100644 src/concepts/interfaces/batch-concepts-options.ts create mode 100644 src/concepts/interfaces/concept-relationships-options.ts create mode 100644 src/concepts/interfaces/concept.ts create mode 100644 src/concepts/interfaces/get-concept-by-code-options.ts create mode 100644 src/concepts/interfaces/get-concept-options.ts create mode 100644 src/concepts/interfaces/index.ts create mode 100644 src/concepts/interfaces/recommended-concepts-options.ts create mode 100644 src/concepts/interfaces/related-concepts-options.ts create mode 100644 src/concepts/interfaces/suggest-concepts-options.ts create mode 100644 src/domains/domains.ts create mode 100644 src/domains/interfaces/domain-concepts-options.ts create mode 100644 src/domains/interfaces/domain.ts create mode 100644 src/domains/interfaces/index.ts create mode 100644 src/domains/interfaces/list-domains-options.ts create mode 100644 src/vocabularies/interfaces/vocabulary-concepts-options.ts create mode 100644 test/common/utils/synthetic-error.test.ts create mode 100644 test/concepts/concepts.test.ts create mode 100644 test/domains/domains.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c33aff..1f9d197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22, 24] + node-version: [22, 24] steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9583b16..cab8244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,14 @@ All notable changes to this project will be documented in this file. The format - Retries are now gated by `isRetryableRequest(method, headers)`: idempotent verbs (GET/HEAD/OPTIONS/PUT/DELETE) always retry, but POST/PATCH only retry when an `Idempotency-Key` header is set. Prevents accidental duplicate writes on transient failures. - Response body is drained via `response.body?.cancel()` before sleeping for a retry, releasing the undici connection back to the pool. - `publishConfig.access` set to `public` so scoped-package publishes don't fall through to npm's default restricted access. +- **Resource method shape switched from "two options objects" to "positional path args + merged options"** — e.g. `client.concepts.get(201826, { includeRelationships: true })` rather than `client.concepts.get({ conceptId: 201826, includeRelationships: true })`. Matches Python/R ergonomics. `vocabularies.list` migrated to the new shape. + +### Added (resources) + +- `client.concepts` — 7 methods: `get`, `getByCode`, `batch`, `suggest`, `related`, `relationships`, `recommended`. `concepts.get(0)` accepts the OMOP unmapped sentinel (R-SDK bug fix). `batch` validates 1–100 IDs synthetically; `recommended` validates `conceptIds` ≤ 100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. +- `client.vocabularies` extended with 6 methods: `get`, `stats`, `domainStats`, `domains` (vocab-scoped), `conceptClasses`, `concepts`. +- `client.domains` — 2 methods: `list`, `concepts`. +- `syntheticError(name, message, details?)` helper in `common/utils/` — builds wire-shaped errors for client-side validation without issuing a network call. diff --git a/README.md b/README.md index 884a0b8..e285685 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # @omophub/omophub-node -Official Node.js / TypeScript SDK for the [OMOPHub API](https://omophub.com) — search, lookup, map, and navigate OHDSI medical vocabularies (SNOMED, ICD10, RxNorm, LOINC, and 100+ more) from JavaScript. - -> **Status — v0.0.x scaffold.** Public API surfaces (concepts, search, vocabularies, hierarchy, mappings, FHIR) ship in upcoming releases. See [`docs/omophub-node-sdk-implementation-plan.md`](https://github.com/OMOPHub/oh-platform) in the platform repo for the roadmap. +Official Node.js / TypeScript SDK for [OMOPHub](https://omophub.com) — search, lookup, map, and navigate OHDSI medical vocabularies (SNOMED, ICD10, RxNorm, LOINC, and 100+ more) from JavaScript. API reference at [docs.omophub.com](https://docs.omophub.com). ## Install @@ -10,7 +8,7 @@ Official Node.js / TypeScript SDK for the [OMOPHub API](https://omophub.com) — npm install @omophub/omophub-node ``` -Requires Node ≥ 20. Runs in Node, modern browsers (CORS permitting), and edge runtimes (Cloudflare Workers, Vercel Edge). +Requires Node ≥ 22. Runs in Node, modern browsers (CORS permitting), and edge runtimes (Cloudflare Workers, Vercel Edge). ## Quick start diff --git a/package.json b/package.json index d4d8fec..4fada7d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dist" ], "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public" diff --git a/src/client.ts b/src/client.ts index 47824d6..4365729 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,8 @@ import { mergeHeaders } from './common/utils/merge-headers.js'; import { connectionError, parseErrorResponse, timeoutError } from './common/utils/parse-error.js'; import { sleep } from './common/utils/sleep.js'; import { unwrapEnvelope } from './common/utils/unwrap-envelope.js'; +import { Concepts } from './concepts/concepts.js'; +import { Domains } from './domains/domains.js'; import { OMOPHubError } from './errors.js'; import type { Response as OMOPHubResponse } from './interfaces.js'; import { __version__ } from './version.js'; @@ -61,6 +63,8 @@ export class OMOPHub { readonly #headers: Headers; readonly #fetch: typeof fetch; + readonly concepts: Concepts; + readonly domains: Domains; readonly vocabularies: Vocabularies; constructor(apiKey?: string, options: OMOPHubOptions = {}) { @@ -88,6 +92,8 @@ export class OMOPHub { const fetchImpl = options.fetch ?? globalThis.fetch; this.#fetch = fetchImpl.bind(globalThis); + this.concepts = new Concepts(this); + this.domains = new Domains(this); this.vocabularies = new Vocabularies(this); } diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts index 3087e36..d74585d 100644 --- a/src/common/utils/backoff.ts +++ b/src/common/utils/backoff.ts @@ -12,19 +12,18 @@ export function isRetryableStatus(status: number): boolean { /** * Computes the delay before the next retry attempt. * - * - Honours `Retry-After` header (seconds or HTTP-date) up to a 60s cap, - * floored to a 100ms minimum so a `Retry-After: 0` doesn't translate to - * an immediate-spam retry. - * - Otherwise full-jitter exponential backoff: - * `min(500 * 2^attempt, 8000) * (1 - 0.25 * random())` - * - * Mirrors the OMOPHub Python SDK's `_calculate_retry_delay`. + * - Honours `Retry-After` header (seconds or HTTP-date) — clamped to + * `[100ms, 60s]`. A value of 0 becomes 100ms (no spam-retry) and a + * value over 60s is capped at 60s (the server's signal isn't ignored). + * - When no Retry-After is present, full-jitter exponential backoff: + * `min(500 * 2^attempt, 8000) * (1 - 0.25 * random())`. */ export function backoffMs(attempt: number, retryAfter: string | null): number { if (retryAfter) { const seconds = parseRetryAfter(retryAfter); - if (seconds !== null && seconds <= MAX_RETRY_AFTER_SEC) { - return Math.max(seconds * 1000, MIN_RETRY_AFTER_MS); + if (seconds !== null) { + const cappedMs = Math.min(seconds, MAX_RETRY_AFTER_SEC) * 1000; + return Math.max(cappedMs, MIN_RETRY_AFTER_MS); } } const exp = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); diff --git a/src/common/utils/build-query.ts b/src/common/utils/build-query.ts index e1eb8c0..c511c08 100644 --- a/src/common/utils/build-query.ts +++ b/src/common/utils/build-query.ts @@ -8,6 +8,10 @@ import { camelToSnakeCase } from './to-snake-case.js'; * - Arrays are comma-joined (matches the OMOPHub API convention). * - Empty arrays are dropped. * - Booleans and numbers are stringified. + * - Duplicates (after snake-casing) are last-write-wins via `set`, so a + * resource that spreads `{ ...flags, ...userQuery }` lets explicit + * user-supplied keys override the SDK's defaults predictably — whether + * the user wrote `pageSize` or `page_size`. * * Returns the encoded query body **without** a leading `?`. */ @@ -19,9 +23,9 @@ export function buildQuery(params: Record | undefined): stri const snakeKey = camelToSnakeCase(key); if (Array.isArray(value)) { if (value.length === 0) continue; - search.append(snakeKey, value.join(',')); + search.set(snakeKey, value.join(',')); } else { - search.append(snakeKey, String(value)); + search.set(snakeKey, String(value)); } } return search.toString(); diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 7ce44bf..4b51377 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -4,5 +4,6 @@ export { envOr, envOrUndefined } from './env.js'; export { mergeHeaders } from './merge-headers.js'; export { connectionError, parseErrorResponse, timeoutError } from './parse-error.js'; export { sleep } from './sleep.js'; +export { syntheticError } from './synthetic-error.js'; export { camelToSnakeCase, toSnakeCaseKeys } from './to-snake-case.js'; export { type UnwrappedEnvelope, unwrapEnvelope } from './unwrap-envelope.js'; diff --git a/src/common/utils/parse-error.ts b/src/common/utils/parse-error.ts index 0178055..7cbf568 100644 --- a/src/common/utils/parse-error.ts +++ b/src/common/utils/parse-error.ts @@ -56,12 +56,12 @@ export async function parseErrorResponse(response: Response): Promise | undefined { } function pickErrorCode(status: number, body: unknown): OMOPHUB_ERROR_CODE_KEY { - const mapped = STATUS_TO_CODE[status]; - if (mapped) return mapped; - - if (status >= 500 && status < 600) { - return status === 503 ? 'service_unavailable' : 'internal_server_error'; - } - + // Server-supplied codes are more specific than the generic HTTP-status + // bucket — e.g. a 400 with `error.code: 'missing_required_field'` should + // surface that, not the generic `validation_error`. Check body first. if (body && typeof body === 'object') { const b = body as Record; const inner = @@ -127,5 +123,13 @@ function pickErrorCode(status: number, body: unknown): OMOPHUB_ERROR_CODE_KEY { return candidate as OMOPHUB_ERROR_CODE_KEY; } } + + const mapped = STATUS_TO_CODE[status]; + if (mapped) return mapped; + + if (status >= 500 && status < 600) { + return status === 503 ? 'service_unavailable' : 'internal_server_error'; + } + return 'application_error'; } diff --git a/src/common/utils/synthetic-error.ts b/src/common/utils/synthetic-error.ts new file mode 100644 index 0000000..bb9cccb --- /dev/null +++ b/src/common/utils/synthetic-error.ts @@ -0,0 +1,21 @@ +import type { ErrorResponse, OMOPHUB_ERROR_CODE_KEY, Response } from '../../interfaces.js'; + +/** + * Builds a `Response` whose error came from the SDK itself rather than + * the wire — used by resources to short-circuit before issuing a request + * (size caps, XOR validation, etc.). The shape matches what a real API + * error would look like so callers have one error path regardless of source. + * + * `statusCode` is null because no HTTP exchange occurred, and `headers` is + * null for the same reason — distinguishing wire errors (have headers) from + * synthetic ones (don't) without forcing callers to care. + */ +export function syntheticError( + name: OMOPHUB_ERROR_CODE_KEY, + message: string, + details?: Record, +): Response { + const error: ErrorResponse = { name, message, statusCode: null }; + if (details) error.details = details; + return { data: null, error, meta: null, headers: null }; +} diff --git a/src/common/utils/to-snake-case.ts b/src/common/utils/to-snake-case.ts index f8b7bcb..bcda77c 100644 --- a/src/common/utils/to-snake-case.ts +++ b/src/common/utils/to-snake-case.ts @@ -19,14 +19,16 @@ export function camelToSnakeCase(input: string): string { /** * Recursively converts an object's keys from camelCase to snake_case. - * Arrays are mapped element-wise; primitives pass through. Used for - * request bodies only — responses stay snake_case to match the API. + * Arrays are mapped element-wise; primitives and non-plain objects + * (Date, Map, Set, class instances, etc.) pass through unchanged so we + * don't silently flatten them to `{}`. Used for request bodies only — + * responses stay snake_case to match the API. */ export function toSnakeCaseKeys(input: T): T { if (Array.isArray(input)) { return input.map((item) => toSnakeCaseKeys(item)) as unknown as T; } - if (input !== null && typeof input === 'object') { + if (isPlainObject(input)) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { out[camelToSnakeCase(k)] = toSnakeCaseKeys(v); @@ -35,3 +37,14 @@ export function toSnakeCaseKeys(input: T): T { } return input; } + +/** + * Plain object = `{}` literal or `Object.create(null)`. Anything with a + * non-Object prototype (Date, Buffer, URL, Map, Set, user classes) is + * excluded so its data is preserved as-is. + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} diff --git a/src/concepts/concepts.ts b/src/concepts/concepts.ts new file mode 100644 index 0000000..65c3732 --- /dev/null +++ b/src/concepts/concepts.ts @@ -0,0 +1,205 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PaginatedData } from '../common/interfaces/pagination.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { BatchConceptsOptions } from './interfaces/batch-concepts-options.js'; +import type { + BatchConceptResult, + Concept, + ConceptRelationshipsResult, + ConceptSuggestion, + RecommendedConceptsResult, + RelatedConceptsResult, +} from './interfaces/concept.js'; +import type { ConceptRelationshipsOptions } from './interfaces/concept-relationships-options.js'; +import type { GetConceptByCodeOptions } from './interfaces/get-concept-by-code-options.js'; +import type { GetConceptOptions } from './interfaces/get-concept-options.js'; +import type { RecommendedConceptsOptions } from './interfaces/recommended-concepts-options.js'; +import type { RelatedConceptsOptions } from './interfaces/related-concepts-options.js'; +import type { SuggestConceptsOptions } from './interfaces/suggest-concepts-options.js'; + +export class Concepts { + constructor(private readonly client: OMOPHub) {} + + /** + * Fetch a single concept by its OMOP `concept_id`. + * + * Accepts `conceptId === 0` (the OMOP "unmapped" sentinel) — the SDK + * does not pre-validate. The server is the source of truth on what's + * a valid ID. + * + * @see https://docs.omophub.com/api-reference/concepts/get + */ + async get( + conceptId: number, + options: GetConceptOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Fetch a concept by its native vocabulary code (e.g. SNOMED `44054006`). + * + * @see https://docs.omophub.com/api-reference/concepts/get-by-code + */ + async getByCode( + vocabularyId: string, + conceptCode: string, + options: GetConceptByCodeOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get( + `/concepts/by-code/${encodeURIComponent(vocabularyId)}/${encodeURIComponent(conceptCode)}`, + { signal, headers, query: { ...flags, ...query } }, + ); + } + + /** + * Fetch up to 100 concepts in a single request. + * + * Returns a `validation_error` synthetically (no network call) if + * `conceptIds` is missing, not an array, or outside `[1, 100]`. + * The `Array.isArray` check defends against JS callers / `// @ts-expect-error` + * users — TS strict mode catches it at compile time. + */ + async batch( + options: BatchConceptsOptions & PostOptions, + ): Promise> { + const { conceptIds, signal, headers, query, idempotencyKey, ...rest } = options; + if (!Array.isArray(conceptIds) || conceptIds.length < 1 || conceptIds.length > 100) { + return syntheticError( + 'validation_error', + '`conceptIds` must be an array of 1–100 items.', + ); + } + const body = toSnakeCaseKeys({ conceptIds, ...rest }); + return this.client.post('/concepts/batch', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Auto-complete suggestions for a free-text query. + * + * `query` is positional so it doesn't collide with `PerCallOptions.query`. + */ + async suggest( + query: string, + options: SuggestConceptsOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query: extraQuery, ...flags } = options; + return this.client.get>( + '/concepts/suggest', + { signal, headers, query: { query, ...flags, ...extraQuery } }, + ); + } + + /** + * Phoebe-style related concepts ranked by relatedness score. + */ + async related( + conceptId: number, + options: RelatedConceptsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/related`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Concept-centric view of `GET /concepts/{id}/relationships`. Shares the + * underlying endpoint with `client.relationships.get(conceptId)` — keep + * the two in sync as new query params are added. + */ + async relationships( + conceptId: number, + options: ConceptRelationshipsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/relationships`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * OHDSI Phoebe-style recommendations for one or more source concepts. + * + * Validates client-side: `conceptIds` 1–100, `relationshipTypes` ≤ 20, + * `vocabularyIds`/`domainIds` ≤ 50 — returns `validation_error` + * synthetically when any cap is exceeded. + */ + async recommended( + options: RecommendedConceptsOptions & PostOptions, + ): Promise> { + const { + conceptIds, + relationshipTypes, + vocabularyIds, + domainIds, + standardOnly, + includeInvalid, + page, + pageSize, + signal, + headers, + query, + idempotencyKey, + } = options; + + if (!Array.isArray(conceptIds) || conceptIds.length < 1 || conceptIds.length > 100) { + return syntheticError( + 'validation_error', + '`conceptIds` must be an array of 1–100 items.', + ); + } + if (relationshipTypes && relationshipTypes.length > 20) { + return syntheticError( + 'validation_error', + '`relationshipTypes` must contain at most 20 entries.', + ); + } + if (vocabularyIds && vocabularyIds.length > 50) { + return syntheticError( + 'validation_error', + '`vocabularyIds` must contain at most 50 entries.', + ); + } + if (domainIds && domainIds.length > 50) { + return syntheticError( + 'validation_error', + '`domainIds` must contain at most 50 entries.', + ); + } + + const body = toSnakeCaseKeys({ + conceptIds, + relationshipTypes, + vocabularyIds, + domainIds, + standardOnly, + includeInvalid, + }); + return this.client.post('/concepts/recommended', body, { + signal, + headers, + query: { page, page_size: pageSize, ...query }, + idempotencyKey, + }); + } +} diff --git a/src/concepts/interfaces/batch-concepts-options.ts b/src/concepts/interfaces/batch-concepts-options.ts new file mode 100644 index 0000000..928b0e6 --- /dev/null +++ b/src/concepts/interfaces/batch-concepts-options.ts @@ -0,0 +1,11 @@ +export interface BatchConceptsOptions { + /** 1–100 concept IDs to look up in a single request. */ + conceptIds: number[]; + includeRelationships?: boolean; + includeSynonyms?: boolean; + includeMappings?: boolean; + /** Restrict returned concepts to a list of vocabulary IDs. */ + vocabularyFilter?: string[]; + /** When true, returns only `standard_concept = 'S'` rows. Defaults true at the API. */ + standardOnly?: boolean; +} diff --git a/src/concepts/interfaces/concept-relationships-options.ts b/src/concepts/interfaces/concept-relationships-options.ts new file mode 100644 index 0000000..6757d47 --- /dev/null +++ b/src/concepts/interfaces/concept-relationships-options.ts @@ -0,0 +1,17 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Options for `concepts.relationships(conceptId, ...)`. + * + * Note: shares the wire endpoint `GET /concepts/{id}/relationships` with + * `relationships.get(conceptId, ...)`. Kept as a separate method so users + * who think in concept-centric terms have a discoverable entry point. + */ +export interface ConceptRelationshipsOptions extends VocabReleaseMixin { + relationshipIds?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + includeInvalid?: boolean; + standardOnly?: boolean; + includeReverse?: boolean; +} diff --git a/src/concepts/interfaces/concept.ts b/src/concepts/interfaces/concept.ts new file mode 100644 index 0000000..17983a9 --- /dev/null +++ b/src/concepts/interfaces/concept.ts @@ -0,0 +1,105 @@ +/** + * OMOP concept payload shapes. Field names are snake_case to match the + * wire format exactly — no client-side translation on response bodies. + * + * Shapes are derived from the Python SDK TypedDicts; nested fields like + * `relationships` and `hierarchy` only appear when the matching `include_*` + * flag is set on the request. + */ + +export interface Synonym { + concept_synonym_name: string; + language_concept_id?: number; +} + +export interface ConceptSummary { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; +} + +export interface ConceptRelationship { + relationship_id: string; + relationship_name?: string; + direction?: 'forward' | 'reverse'; + target_concept_id: number; + target_concept_name: string; + target_vocabulary_id: string; + target_concept_code: string; + target_domain_id?: string; + target_concept_class_id?: string; + target_standard_concept?: 'S' | 'C' | 'N' | null; + invalid_reason?: string | null; +} + +export interface ConceptHierarchyNode { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; + level?: number; + min_levels_of_separation?: number; + max_levels_of_separation?: number; +} + +export interface Concept extends ConceptSummary { + domain_id: string; + concept_class_id: string; + standard_concept: 'S' | 'C' | 'N' | null; + valid_start_date: string; + valid_end_date: string; + invalid_reason?: string | null; + synonyms?: Synonym[]; + relationships?: ConceptRelationship[]; + hierarchy?: { + ancestors?: ConceptHierarchyNode[]; + descendants?: ConceptHierarchyNode[]; + }; +} + +export interface RelatedConcept extends ConceptSummary { + relatedness_score: number; + relatedness_type: string; + hierarchical_score?: number; + semantic_score?: number; + co_occurrence_score?: number; + mapping_score?: number; + explanation?: string; +} + +export interface BatchConceptResult { + concepts: Concept[]; + failed_concepts?: { concept_id: number; reason?: string }[]; + summary?: { + total?: number; + found?: number; + failed?: number; + }; +} + +export interface ConceptSuggestion { + suggestion: string; + type: string; + match_type: string; + match_score: number; + concept_id: number; + vocabulary_id: string; +} + +export interface RelatedConceptsResult { + related_concepts: RelatedConcept[]; +} + +export interface ConceptRelationshipsResult { + relationships: ConceptRelationship[]; +} + +export interface ConceptRecommendation { + source_concept_id: number; + recommendations: RelatedConcept[]; +} + +export interface RecommendedConceptsResult { + recommendations: ConceptRecommendation[]; +} diff --git a/src/concepts/interfaces/get-concept-by-code-options.ts b/src/concepts/interfaces/get-concept-by-code-options.ts new file mode 100644 index 0000000..2124175 --- /dev/null +++ b/src/concepts/interfaces/get-concept-by-code-options.ts @@ -0,0 +1,7 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface GetConceptByCodeOptions extends VocabReleaseMixin { + includeRelationships?: boolean; + includeSynonyms?: boolean; + includeHierarchy?: boolean; +} diff --git a/src/concepts/interfaces/get-concept-options.ts b/src/concepts/interfaces/get-concept-options.ts new file mode 100644 index 0000000..a771d22 --- /dev/null +++ b/src/concepts/interfaces/get-concept-options.ts @@ -0,0 +1,7 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface GetConceptOptions extends VocabReleaseMixin { + includeRelationships?: boolean; + includeSynonyms?: boolean; + includeHierarchy?: boolean; +} diff --git a/src/concepts/interfaces/index.ts b/src/concepts/interfaces/index.ts new file mode 100644 index 0000000..20445c3 --- /dev/null +++ b/src/concepts/interfaces/index.ts @@ -0,0 +1,21 @@ +export type { BatchConceptsOptions } from './batch-concepts-options.js'; +export type { + BatchConceptResult, + Concept, + ConceptHierarchyNode, + ConceptRecommendation, + ConceptRelationship, + ConceptRelationshipsResult, + ConceptSuggestion, + ConceptSummary, + RecommendedConceptsResult, + RelatedConcept, + RelatedConceptsResult, + Synonym, +} from './concept.js'; +export type { ConceptRelationshipsOptions } from './concept-relationships-options.js'; +export type { GetConceptByCodeOptions } from './get-concept-by-code-options.js'; +export type { GetConceptOptions } from './get-concept-options.js'; +export type { RecommendedConceptsOptions } from './recommended-concepts-options.js'; +export type { RelatedConceptsOptions } from './related-concepts-options.js'; +export type { SuggestConceptsOptions } from './suggest-concepts-options.js'; diff --git a/src/concepts/interfaces/recommended-concepts-options.ts b/src/concepts/interfaces/recommended-concepts-options.ts new file mode 100644 index 0000000..39e6878 --- /dev/null +++ b/src/concepts/interfaces/recommended-concepts-options.ts @@ -0,0 +1,15 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +/** + * OHDSI Phoebe-style concept recommendations. Server-side caps: + * `conceptIds` 1–100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. + */ +export interface RecommendedConceptsOptions extends PaginationOptions { + conceptIds: number[]; + relationshipTypes?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + /** Defaults true at the API — restrict candidates to standard concepts. */ + standardOnly?: boolean; + includeInvalid?: boolean; +} diff --git a/src/concepts/interfaces/related-concepts-options.ts b/src/concepts/interfaces/related-concepts-options.ts new file mode 100644 index 0000000..e172fb7 --- /dev/null +++ b/src/concepts/interfaces/related-concepts-options.ts @@ -0,0 +1,9 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface RelatedConceptsOptions extends VocabReleaseMixin { + relationshipTypes?: string[]; + /** Filter results by minimum relatedness score (0–1). */ + minScore?: number; + /** Default 20 at the API; max 100. */ + pageSize?: number; +} diff --git a/src/concepts/interfaces/suggest-concepts-options.ts b/src/concepts/interfaces/suggest-concepts-options.ts new file mode 100644 index 0000000..e08110f --- /dev/null +++ b/src/concepts/interfaces/suggest-concepts-options.ts @@ -0,0 +1,12 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Note: the free-text `query` is the first positional argument to + * `concepts.suggest()` — it would otherwise collide with + * `PerCallOptions.query` (the escape-hatch query-param record). + */ +export interface SuggestConceptsOptions extends PaginationOptions, VocabReleaseMixin { + vocabularyIds?: string[]; + domainIds?: string[]; +} diff --git a/src/domains/domains.ts b/src/domains/domains.ts new file mode 100644 index 0000000..9f5737b --- /dev/null +++ b/src/domains/domains.ts @@ -0,0 +1,47 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PaginatedData } from '../common/interfaces/pagination.js'; +import type { ConceptSummary } from '../concepts/interfaces/concept.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { Domain } from './interfaces/domain.js'; +import type { DomainConceptsOptions } from './interfaces/domain-concepts-options.js'; +import type { ListDomainsOptions } from './interfaces/list-domains-options.js'; + +export class Domains { + constructor(private readonly client: OMOPHub) {} + + /** + * List all OMOP domains. Distinct from `vocabularies.domains()` — + * this endpoint (`/domains`) returns the canonical domain catalog, + * while the vocabularies version returns domains scoped to vocabulary + * usage. + * + * @see https://docs.omophub.com/api-reference/domains/list + */ + async list( + options: ListDomainsOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query, ...flags } = options; + return this.client.get>('/domains', { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List the concepts that belong to a specific domain. + * + * @see https://docs.omophub.com/api-reference/domains/concepts + */ + async concepts( + domainId: string, + options: DomainConceptsOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query, ...flags } = options; + return this.client.get>( + `/domains/${encodeURIComponent(domainId)}/concepts`, + { signal, headers, query: { ...flags, ...query } }, + ); + } +} diff --git a/src/domains/interfaces/domain-concepts-options.ts b/src/domains/interfaces/domain-concepts-options.ts new file mode 100644 index 0000000..61c3754 --- /dev/null +++ b/src/domains/interfaces/domain-concepts-options.ts @@ -0,0 +1,8 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface DomainConceptsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** When true, returns only `standard_concept = 'S'` rows. */ + standardOnly?: boolean; + includeInvalid?: boolean; +} diff --git a/src/domains/interfaces/domain.ts b/src/domains/interfaces/domain.ts new file mode 100644 index 0000000..4252bd5 --- /dev/null +++ b/src/domains/interfaces/domain.ts @@ -0,0 +1,22 @@ +export interface DomainStats { + total_concepts: number; + standard_concepts?: number; + classification_concepts?: number; + vocabulary_distribution?: Record; + concept_class_distribution?: Record; + growth_trend?: Record; + usage_frequency?: number; + relationship_density?: number; +} + +export interface DomainSummary { + domain_id: string; + domain_name: string; + domain_concept_id?: number; +} + +export interface Domain extends DomainSummary { + stats?: DomainStats; + category?: string; + description?: string; +} diff --git a/src/domains/interfaces/index.ts b/src/domains/interfaces/index.ts new file mode 100644 index 0000000..17587e6 --- /dev/null +++ b/src/domains/interfaces/index.ts @@ -0,0 +1,3 @@ +export type { Domain, DomainStats, DomainSummary } from './domain.js'; +export type { DomainConceptsOptions } from './domain-concepts-options.js'; +export type { ListDomainsOptions } from './list-domains-options.js'; diff --git a/src/domains/interfaces/list-domains-options.ts b/src/domains/interfaces/list-domains-options.ts new file mode 100644 index 0000000..17a1ca3 --- /dev/null +++ b/src/domains/interfaces/list-domains-options.ts @@ -0,0 +1,4 @@ +export interface ListDomainsOptions { + /** Embed per-domain statistics in each result. */ + includeStats?: boolean; +} diff --git a/src/index.ts b/src/index.ts index 68773bd..7450fce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,34 @@ export type { RequireExactlyOne, VocabReleaseMixin, } from './common/interfaces/index.js'; +export type { + BatchConceptResult, + BatchConceptsOptions, + Concept, + ConceptHierarchyNode, + ConceptRecommendation, + ConceptRelationship, + ConceptRelationshipsOptions, + ConceptRelationshipsResult, + ConceptSuggestion, + ConceptSummary, + GetConceptByCodeOptions, + GetConceptOptions, + RecommendedConceptsOptions, + RecommendedConceptsResult, + RelatedConcept, + RelatedConceptsOptions, + RelatedConceptsResult, + SuggestConceptsOptions, + Synonym, +} from './concepts/interfaces/index.js'; +export type { + Domain, + DomainConceptsOptions, + DomainStats, + DomainSummary, + ListDomainsOptions, +} from './domains/interfaces/index.js'; export { OMOPHubError, OMOPHubIteratorError } from './errors.js'; export type { ErrorResponse, @@ -27,8 +55,11 @@ export type { } from './interfaces.js'; export { __version__ } from './version.js'; export type { + ConceptClass, ListVocabulariesOptions, Vocabulary, + VocabularyConceptsOptions, + VocabularyDomain, VocabularyStats, VocabularySummary, } from './vocabularies/interfaces/index.js'; diff --git a/src/vocabularies/interfaces/index.ts b/src/vocabularies/interfaces/index.ts index 591de49..603aaf2 100644 --- a/src/vocabularies/interfaces/index.ts +++ b/src/vocabularies/interfaces/index.ts @@ -1,2 +1,9 @@ export type { ListVocabulariesOptions } from './list-vocabularies-options.js'; -export type { Vocabulary, VocabularyStats, VocabularySummary } from './vocabulary.js'; +export type { + ConceptClass, + Vocabulary, + VocabularyDomain, + VocabularyStats, + VocabularySummary, +} from './vocabulary.js'; +export type { VocabularyConceptsOptions } from './vocabulary-concepts-options.js'; diff --git a/src/vocabularies/interfaces/vocabulary-concepts-options.ts b/src/vocabularies/interfaces/vocabulary-concepts-options.ts new file mode 100644 index 0000000..07b7615 --- /dev/null +++ b/src/vocabularies/interfaces/vocabulary-concepts-options.ts @@ -0,0 +1,13 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface VocabularyConceptsOptions extends PaginationOptions { + /** Free-text search inside the vocabulary. */ + search?: string; + /** `'S'` standard, `'C'` classification, `'all'` (default), or `'N'` non-standard. */ + standardConcept?: 'S' | 'C' | 'N' | 'all'; + includeInvalid?: boolean; + includeRelationships?: boolean; + includeSynonyms?: boolean; + sortBy?: 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/vocabularies/interfaces/vocabulary.ts b/src/vocabularies/interfaces/vocabulary.ts index 3e87ffa..81cec18 100644 --- a/src/vocabularies/interfaces/vocabulary.ts +++ b/src/vocabularies/interfaces/vocabulary.ts @@ -26,3 +26,23 @@ export interface VocabularyStats { export interface VocabularySummary extends Vocabulary { stats?: VocabularyStats; } + +/** + * Domain entry returned by `GET /vocabularies/domains`. Distinct from + * the richer `Domain` type returned by `GET /domains` — this one is + * scoped to the vocabulary catalog. + */ +export interface VocabularyDomain { + domain_id: string; + domain_name: string; + domain_concept_id?: number; +} + +/** + * Concept-class entry returned by `GET /vocabularies/concept-classes`. + */ +export interface ConceptClass { + concept_class_id: string; + concept_class_name: string; + concept_class_concept_id?: number; +} diff --git a/src/vocabularies/vocabularies.ts b/src/vocabularies/vocabularies.ts index 96a71a9..4a8eb7d 100644 --- a/src/vocabularies/vocabularies.ts +++ b/src/vocabularies/vocabularies.ts @@ -1,9 +1,16 @@ import type { OMOPHub } from '../client.js'; import type { GetOptions } from '../common/interfaces/get-options.js'; import type { PaginatedData } from '../common/interfaces/pagination.js'; +import type { ConceptSummary } from '../concepts/interfaces/concept.js'; import type { Response as OMOPHubResponse } from '../interfaces.js'; import type { ListVocabulariesOptions } from './interfaces/list-vocabularies-options.js'; -import type { Vocabulary } from './interfaces/vocabulary.js'; +import type { + ConceptClass, + Vocabulary, + VocabularyDomain, + VocabularyStats, +} from './interfaces/vocabulary.js'; +import type { VocabularyConceptsOptions } from './interfaces/vocabulary-concepts-options.js'; export class Vocabularies { constructor(private readonly client: OMOPHub) {} @@ -11,27 +18,84 @@ export class Vocabularies { /** * List vocabularies. * - * ```ts - * const { data, error } = await client.vocabularies.list({ pageSize: 50 }); - * ``` - * * @see https://docs.omophub.com/api-reference/vocabularies/list */ async list( - options: ListVocabulariesOptions = {}, - requestOptions: GetOptions = {}, - ): Promise>> { - return this.client.get>('/vocabularies', { - ...requestOptions, - query: { - page: options.page, - page_size: options.pageSize, - include_stats: options.includeStats, - include_inactive: options.includeInactive, - sort_by: options.sortBy, - sort_order: options.sortOrder, - ...requestOptions.query, - }, + options: ListVocabulariesOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query, ...flags } = options; + return this.client.get>('/vocabularies', { + signal, + headers, + query: { ...flags, ...query }, }); } + + /** + * Fetch a single vocabulary's metadata by its ID (e.g. `SNOMED`, `RxNorm`). + */ + async get(vocabularyId: string, options: GetOptions = {}): Promise> { + return this.client.get( + `/vocabularies/${encodeURIComponent(vocabularyId)}`, + options, + ); + } + + /** + * Concept counts and breakdowns for a single vocabulary. + */ + async stats( + vocabularyId: string, + options: GetOptions = {}, + ): Promise> { + return this.client.get( + `/vocabularies/${encodeURIComponent(vocabularyId)}/stats`, + options, + ); + } + + /** + * Per-domain breakdown for one vocabulary/domain pair. + */ + async domainStats( + vocabularyId: string, + domainId: string, + options: GetOptions = {}, + ): Promise>> { + return this.client.get>( + `/vocabularies/${encodeURIComponent(vocabularyId)}/stats/domains/${encodeURIComponent(domainId)}`, + options, + ); + } + + /** + * Vocabulary-scoped domain catalog. Distinct from `client.domains.list()` + * which hits `/domains` — this one returns domains as they appear in the + * vocabulary metadata table. + */ + async domains(options: GetOptions = {}): Promise> { + return this.client.get('/vocabularies/domains', options); + } + + /** + * Concept-class catalog across all vocabularies. + */ + async conceptClasses(options: GetOptions = {}): Promise> { + return this.client.get('/vocabularies/concept-classes', options); + } + + /** + * Paginated listing of concepts within a single vocabulary, with + * optional search and standard/invalid filters. + */ + async concepts( + vocabularyId: string, + options: VocabularyConceptsOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query, ...flags } = options; + return this.client.get>( + `/vocabularies/${encodeURIComponent(vocabularyId)}/concepts`, + { signal, headers, query: { ...flags, ...query } }, + ); + } } diff --git a/test/common/utils/backoff.test.ts b/test/common/utils/backoff.test.ts index 877b3af..b93f3f1 100644 --- a/test/common/utils/backoff.test.ts +++ b/test/common/utils/backoff.test.ts @@ -31,9 +31,13 @@ describe('backoffMs', () => { expect(backoffMs(5, '30')).toBe(30_000); }); - test('ignores Retry-After above 60s and falls back to exponential', () => { - // 0.25 jitter mocked to 0 → no scaling - expect(backoffMs(0, '120')).toBe(500); + test('caps Retry-After above 60s at 60s rather than dropping it', () => { + expect(backoffMs(0, '120')).toBe(60_000); + expect(backoffMs(0, '600')).toBe(60_000); + }); + + test('floors Retry-After of 0 at the 100ms minimum to avoid spam-retry', () => { + expect(backoffMs(0, '0')).toBe(100); }); test('exponential backoff doubles per attempt, capped at 8s', () => { diff --git a/test/common/utils/build-query.test.ts b/test/common/utils/build-query.test.ts index b4ee2c3..3961bbd 100644 --- a/test/common/utils/build-query.test.ts +++ b/test/common/utils/build-query.test.ts @@ -35,6 +35,12 @@ describe('buildQuery', () => { 'page=1&page_size=50&include_stats=true', ); }); + + test('deduplicates keys after snake-case conversion — last write wins', () => { + // Simulates `{ ...flags (camelCase), ...userQuery (snake_case) }` merge. + // Without dedup, the URL would carry `page_size=10&page_size=20`. + expect(buildQuery({ pageSize: 10, page_size: 20 })).toBe('page_size=20'); + }); }); describe('appendQuery', () => { diff --git a/test/common/utils/parse-error.test.ts b/test/common/utils/parse-error.test.ts index 85747dc..bec699f 100644 --- a/test/common/utils/parse-error.test.ts +++ b/test/common/utils/parse-error.test.ts @@ -102,6 +102,24 @@ describe('parseErrorResponse', () => { ); expect(err.name).toBe('tier_limit_exceeded'); }); + + test('body error code wins over the status map when both are present', async () => { + const err = await parseErrorResponse( + makeResponse(400, { + success: false, + error: { code: 'missing_required_field', message: 'no concept_id' }, + }), + ); + expect(err.name).toBe('missing_required_field'); + }); + + test('parses Retry-After on 503 (not just 429)', async () => { + const err = await parseErrorResponse( + makeResponse(503, { message: 'down' }, { 'retry-after': '30' }), + ); + expect(err.name).toBe('service_unavailable'); + expect(err.retryAfter).toBe(30); + }); }); describe('connectionError', () => { diff --git a/test/common/utils/synthetic-error.test.ts b/test/common/utils/synthetic-error.test.ts new file mode 100644 index 0000000..4a7d14b --- /dev/null +++ b/test/common/utils/synthetic-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; +import { syntheticError } from '../../../src/common/utils/synthetic-error.js'; + +describe('syntheticError', () => { + test('builds a Response with data null and the supplied error fields', () => { + const result = syntheticError<{ id: number }>('validation_error', 'bad input', { + field: 'concept_id', + }); + expect(result.data).toBeNull(); + expect(result.error?.name).toBe('validation_error'); + expect(result.error?.message).toBe('bad input'); + expect(result.error?.statusCode).toBeNull(); + expect(result.error?.details).toEqual({ field: 'concept_id' }); + expect(result.meta).toBeNull(); + expect(result.headers).toBeNull(); + }); + + test('omits details when not provided', () => { + const result = syntheticError('not_found', 'gone'); + expect(result.error?.details).toBeUndefined(); + }); +}); diff --git a/test/common/utils/to-snake-case.test.ts b/test/common/utils/to-snake-case.test.ts index e280a98..15505ba 100644 --- a/test/common/utils/to-snake-case.test.ts +++ b/test/common/utils/to-snake-case.test.ts @@ -56,4 +56,33 @@ describe('toSnakeCaseKeys', () => { const input = [{ conceptId: 1 }, { conceptId: 2 }]; expect(toSnakeCaseKeys(input)).toEqual([{ concept_id: 1 }, { concept_id: 2 }]); }); + + test('preserves Date instances rather than collapsing them to {}', () => { + const d = new Date('2026-05-29T00:00:00Z'); + const input = { startedAt: d, count: 5 }; + const out = toSnakeCaseKeys(input); + expect(out).toEqual({ started_at: d, count: 5 }); + expect(out.started_at).toBeInstanceOf(Date); + }); + + test('preserves Map and Set instances', () => { + const m = new Map([['a', 1]]); + const s = new Set([1, 2]); + const out = toSnakeCaseKeys({ someMap: m, someSet: s }); + expect(out.some_map).toBe(m); + expect(out.some_set).toBe(s); + }); + + test('preserves user class instances', () => { + class Coding { + constructor( + public system: string, + public code: string, + ) {} + } + const c = new Coding('http://snomed.info/sct', '44054006'); + const out = toSnakeCaseKeys({ sourceCoding: c }); + expect(out.source_coding).toBe(c); + expect(out.source_coding).toBeInstanceOf(Coding); + }); }); diff --git a/test/concepts/concepts.test.ts b/test/concepts/concepts.test.ts new file mode 100644 index 0000000..2f627ce --- /dev/null +++ b/test/concepts/concepts.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { + DIABETES_CONCEPT_ID, + mockApiErrorBody, + mockConcept, + mockPagination, +} from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.concepts.get', () => { + test('hits GET /concepts/{id}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.concepts.get(DIABETES_CONCEPT_ID); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(DIABETES_CONCEPT_ID); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/201826'); + }); + + test('accepts concept_id = 0 (the unmapped sentinel; R-SDK bug fix)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept({ concept_id: 0, concept_name: 'No matching concept' })); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.concepts.get(0); + expect(error).toBeNull(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/0'); + }); + + test('serialises include_* flags + vocab_release as snake-case query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.get(DIABETES_CONCEPT_ID, { + includeRelationships: true, + includeSynonyms: true, + includeHierarchy: false, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('include_relationships=true'); + expect(url).toContain('include_synonyms=true'); + expect(url).toContain('include_hierarchy=false'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.concepts.get(9_999_999); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.concepts.getByCode', () => { + test('hits GET /concepts/by-code/{vocab}/{code} with URL-encoding', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.getByCode('SNOMED', '44054006'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/concepts/by-code/SNOMED/44054006', + ); + }); + + test('URL-encodes a concept_code that contains a slash', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.getByCode('LOINC', '8480-6/8462-4'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/concepts/by-code/LOINC/8480-6%2F8462-4', + ); + }); +}); + +describe('client.concepts.batch', () => { + test('hits POST /concepts/batch with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.batch({ + conceptIds: [201826, 1112807], + includeRelationships: true, + vocabularyFilter: ['SNOMED', 'RxNorm'], + standardOnly: false, + }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + concept_ids: [201826, 1112807], + include_relationships: true, + vocabulary_filter: ['SNOMED', 'RxNorm'], + standard_only: false, + }); + }); + + test('rejects empty conceptIds synthetically without hitting the network', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.concepts.batch({ conceptIds: [] }); + expect(data).toBeNull(); + expect(error?.name).toBe('validation_error'); + expect(error?.message).toMatch(/1–100/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects >100 conceptIds synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 101 }, (_, i) => i + 1); + const { error } = await client.concepts.batch({ conceptIds: tooMany }); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns validation_error (not TypeError) when JS caller omits conceptIds', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Simulates a JS caller / `as any` user passing a malformed payload. + const { data, error } = await client.concepts.batch( + {} as unknown as Parameters[0], + ); + expect(data).toBeNull(); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns validation_error when conceptIds is not an array', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.concepts.batch({ + conceptIds: 'not-an-array' as unknown as number[], + }); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('forwards idempotencyKey to the Idempotency-Key header', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.batch({ + conceptIds: [1], + idempotencyKey: 'idem_batch_1', + }); + expect(new Headers(lastCall(fetchMock).init.headers).get('idempotency-key')).toBe( + 'idem_batch_1', + ); + }); +}); + +describe('client.concepts.suggest', () => { + test('hits GET /concepts/suggest with paginated query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + data: [{ suggestion: 'diabetes', concept_id: 201826 }], + meta: { pagination: mockPagination() }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.suggest('diab', { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/suggest'); + expect(url).toContain('query=diab'); + expect(url).toContain('page_size=5'); + expect(url).toContain('vocabulary_ids=SNOMED'); + }); +}); + +describe('client.concepts.related', () => { + test('hits GET /concepts/{id}/related', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { related_concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.related(201826, { + relationshipTypes: ['Maps to', 'Is a'], + minScore: 0.5, + pageSize: 20, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/related'); + expect(url).toContain('relationship_types=Maps+to%2CIs+a'); + expect(url).toContain('min_score=0.5'); + }); +}); + +describe('client.concepts.relationships', () => { + test('hits GET /concepts/{id}/relationships (shared endpoint with relationships.get)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationships: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.relationships(201826, { + relationshipIds: ['Maps to'], + standardOnly: true, + includeReverse: true, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/relationships'); + expect(url).toContain('relationship_ids=Maps+to'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_reverse=true'); + }); +}); + +describe('client.concepts.recommended', () => { + test('hits POST /concepts/recommended with body + page query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { recommendations: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.recommended({ + conceptIds: [201826], + relationshipTypes: ['Is a'], + standardOnly: true, + page: 1, + pageSize: 100, + }); + const { url, init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toContain('page=1'); + expect(url).toContain('page_size=100'); + const body = JSON.parse(init.body as string); + expect(body.concept_ids).toEqual([201826]); + expect(body.relationship_types).toEqual(['Is a']); + expect(body.standard_only).toBe(true); + }); + + test('rejects >100 conceptIds, >20 relationshipTypes, >50 vocab/domain ids synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + + expect((await client.concepts.recommended({ conceptIds: [] })).error?.name).toBe( + 'validation_error', + ); + + // JS-caller path: conceptIds missing entirely. + expect( + ( + await client.concepts.recommended( + {} as unknown as Parameters[0], + ) + ).error?.name, + ).toBe('validation_error'); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + relationshipTypes: Array.from({ length: 21 }, (_, i) => `r${i}`), + }) + ).error?.message, + ).toMatch(/relationshipTypes.*20/); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + vocabularyIds: Array.from({ length: 51 }, (_, i) => `v${i}`), + }) + ).error?.message, + ).toMatch(/vocabularyIds.*50/); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + domainIds: Array.from({ length: 51 }, (_, i) => `d${i}`), + }) + ).error?.message, + ).toMatch(/domainIds.*50/); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/domains/domains.test.ts b/test/domains/domains.test.ts new file mode 100644 index 0000000..6e60591 --- /dev/null +++ b/test/domains/domains.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { mockApiErrorBody, mockDomain, mockPagination } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.domains.list', () => { + test('hits GET /domains', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockDomain()]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.domains.list(); + expect(error).toBeNull(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/domains'); + expect(data).toEqual([mockDomain()]); + }); + + test('appends include_stats when requested', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockDomain()]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.list({ includeStats: true }); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/domains?include_stats=true'); + }); +}); + +describe('client.domains.concepts', () => { + test('hits GET /domains/{id}/concepts with snake-cased filters', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.concepts('Condition', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + standardOnly: true, + includeInvalid: false, + page: 2, + pageSize: 50, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/domains/Condition/concepts'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + }); + + test('URL-encodes the domain ID', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.concepts('Drug Exposure'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/domains/Drug%20Exposure/concepts', + ); + }); + + test('returns ErrorResponse on 404 for unknown domain', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'unknown domain')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.domains.concepts('Nope'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 769f024..a474b6d 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -1,4 +1,6 @@ import type { PaginationMeta } from '../../src/common/interfaces/pagination.js'; +import type { Concept } from '../../src/concepts/interfaces/concept.js'; +import type { Domain } from '../../src/domains/interfaces/domain.js'; import type { Vocabulary } from '../../src/vocabularies/interfaces/vocabulary.js'; export const DIABETES_CONCEPT_ID = 201826; @@ -44,3 +46,23 @@ export const mockApiErrorBody = ( success: false as const, error: details ? { code, message, details } : { code, message }, }); + +export const mockConcept = (overrides: Partial = {}): Concept => ({ + concept_id: DIABETES_CONCEPT_ID, + concept_name: 'Type 2 diabetes mellitus', + vocabulary_id: 'SNOMED', + concept_code: '44054006', + domain_id: 'Condition', + concept_class_id: 'Clinical Finding', + standard_concept: 'S', + valid_start_date: '2002-01-31', + valid_end_date: '2099-12-31', + ...overrides, +}); + +export const mockDomain = (overrides: Partial = {}): Domain => ({ + domain_id: 'Condition', + domain_name: 'Condition', + domain_concept_id: 19, + ...overrides, +}); diff --git a/test/helpers/mock-fetch.ts b/test/helpers/mock-fetch.ts index 83eddd6..998f126 100644 --- a/test/helpers/mock-fetch.ts +++ b/test/helpers/mock-fetch.ts @@ -37,9 +37,12 @@ export function enqueueRawBody( export function enqueueError( fetchMock: MockFetch, status: number, + // Default body omits `error.code` so the HTTP-status → error-name mapping + // in parseErrorResponse drives the outcome. Tests that want to assert + // a specific server-supplied code pass it explicitly. body: unknown = { success: false, - error: { code: 'application_error', message: `HTTP ${status}` }, + error: { message: `HTTP ${status}` }, }, headers: Record = {}, ): void { diff --git a/test/vocabularies/vocabularies.test.ts b/test/vocabularies/vocabularies.test.ts index c9bece2..2896a20 100644 --- a/test/vocabularies/vocabularies.test.ts +++ b/test/vocabularies/vocabularies.test.ts @@ -85,10 +85,10 @@ describe('client.vocabularies.list', () => { enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); const client = new OMOPHub('oh_test', { fetch: fetchMock }); - await client.vocabularies.list( - { pageSize: 10 }, - { query: { vocab_release: '2025.1', trace: true } }, - ); + await client.vocabularies.list({ + pageSize: 10, + query: { vocab_release: '2025.1', trace: true }, + }); const { url } = lastCall(fetchMock); expect(url).toContain('page_size=10'); @@ -96,3 +96,97 @@ describe('client.vocabularies.list', () => { expect(url).toContain('trace=true'); }); }); + +describe('client.vocabularies new methods', () => { + test('get hits /vocabularies/{id}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockVocabulary()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.get('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/SNOMED'); + }); + + test('get URL-encodes the vocabulary ID', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockVocabulary()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.get('Vocab With Spaces'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/vocabularies/Vocab%20With%20Spaces', + ); + }); + + test('stats hits /vocabularies/{id}/stats', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { vocabulary_id: 'SNOMED', total_concepts: 350_000 }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.stats('SNOMED'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/SNOMED/stats'); + }); + + test('domainStats hits /vocabularies/{vocab}/stats/domains/{domain}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { domain_id: 'Condition' }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.domainStats('SNOMED', 'Condition'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/vocabularies/SNOMED/stats/domains/Condition', + ); + }); + + test('domains hits /vocabularies/domains (vocab-scoped, NOT /domains)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [{ domain_id: 'Condition', domain_name: 'Condition' }]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.domains(); + expect(error).toBeNull(); + expect(data?.[0]?.domain_id).toBe('Condition'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/domains'); + }); + + test('conceptClasses hits /vocabularies/concept-classes', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [ + { concept_class_id: 'Clinical Finding', concept_class_name: 'Clinical Finding' }, + ]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.conceptClasses(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/concept-classes'); + }); + + test('concepts hits /vocabularies/{id}/concepts with snake-cased options', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.concepts('SNOMED', { + search: 'diabetes', + standardConcept: 'S', + includeInvalid: false, + page: 2, + pageSize: 50, + sortBy: 'name', + sortOrder: 'asc', + }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('/vocabularies/SNOMED/concepts'); + expect(url).toContain('search=diabetes'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + expect(url).toContain('sort_by=name'); + expect(url).toContain('sort_order=asc'); + }); + + test('returns ErrorResponse on 404 for unknown vocabulary', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'vocabulary missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.vocabularies.get('NOPE'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); From 9f6b495b12315afd9e3daa8819bc0c126156b690 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 30 May 2026 09:00:59 +0100 Subject: [PATCH 03/16] Refactor concepts and domains methods for improved query handling and type definitions. Ensure positional `query` argument takes precedence over options, and streamline `GetConceptByCodeOptions` to inherit from `GetConceptOptions`. Update `list` method in domains to return non-paginated results. Add tests to validate new behavior. --- src/concepts/concepts.ts | 5 ++++- .../interfaces/get-concept-by-code-options.ts | 14 ++++++++------ src/domains/domains.ts | 9 +++++---- test/concepts/concepts.test.ts | 13 +++++++++++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/concepts/concepts.ts b/src/concepts/concepts.ts index 65c3732..5209e1b 100644 --- a/src/concepts/concepts.ts +++ b/src/concepts/concepts.ts @@ -93,6 +93,9 @@ export class Concepts { * Auto-complete suggestions for a free-text query. * * `query` is positional so it doesn't collide with `PerCallOptions.query`. + * The positional value is spread LAST so a stray `query` key inside + * `options.query` (the escape-hatch params map) cannot silently override + * the method's primary argument. */ async suggest( query: string, @@ -101,7 +104,7 @@ export class Concepts { const { signal, headers, query: extraQuery, ...flags } = options; return this.client.get>( '/concepts/suggest', - { signal, headers, query: { query, ...flags, ...extraQuery } }, + { signal, headers, query: { ...flags, ...extraQuery, query } }, ); } diff --git a/src/concepts/interfaces/get-concept-by-code-options.ts b/src/concepts/interfaces/get-concept-by-code-options.ts index 2124175..8897301 100644 --- a/src/concepts/interfaces/get-concept-by-code-options.ts +++ b/src/concepts/interfaces/get-concept-by-code-options.ts @@ -1,7 +1,9 @@ -import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; +import type { GetConceptOptions } from './get-concept-options.js'; -export interface GetConceptByCodeOptions extends VocabReleaseMixin { - includeRelationships?: boolean; - includeSynonyms?: boolean; - includeHierarchy?: boolean; -} +/** + * `get` and `getByCode` accept the same optional flags — the only + * difference is how the concept is identified (numeric ID vs. + * vocabulary+code). Aliased rather than duplicated so any new option + * added to `GetConceptOptions` automatically propagates here. + */ +export type GetConceptByCodeOptions = GetConceptOptions; diff --git a/src/domains/domains.ts b/src/domains/domains.ts index 9f5737b..7575ac3 100644 --- a/src/domains/domains.ts +++ b/src/domains/domains.ts @@ -16,13 +16,14 @@ export class Domains { * while the vocabularies version returns domains scoped to vocabulary * usage. * + * Not paginated server-side (Python and R SDKs both call this endpoint + * without page/page_size). Returns the full catalog in a single call. + * * @see https://docs.omophub.com/api-reference/domains/list */ - async list( - options: ListDomainsOptions & GetOptions = {}, - ): Promise>> { + async list(options: ListDomainsOptions & GetOptions = {}): Promise> { const { signal, headers, query, ...flags } = options; - return this.client.get>('/domains', { + return this.client.get('/domains', { signal, headers, query: { ...flags, ...query }, diff --git a/test/concepts/concepts.test.ts b/test/concepts/concepts.test.ts index 2f627ce..5f5c644 100644 --- a/test/concepts/concepts.test.ts +++ b/test/concepts/concepts.test.ts @@ -172,6 +172,19 @@ describe('client.concepts.suggest', () => { expect(url).toContain('page_size=5'); expect(url).toContain('vocabulary_ids=SNOMED'); }); + + test('positional `query` wins over a `query` key inside options.query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.suggest('diab', { + query: { query: 'override-attempt', trace: 'on' }, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('query=diab'); + expect(url).not.toContain('query=override-attempt'); + expect(url).toContain('trace=on'); + }); }); describe('client.concepts.related', () => { From 6d3eeab1d569d3f0b52a786c60db55d918586c03 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 30 May 2026 20:26:09 +0100 Subject: [PATCH 04/16] Enhance OMOPHub SDK with new hierarchy, mappings, and relationships resources. Introduce methods for fetching concept hierarchy, ancestors, descendants, and mappings. Implement pagination utilities and normalize search response shapes. Update changelog to reflect new features and improvements. --- CHANGELOG.md | 9 + src/client.ts | 12 + src/common/utils/index.ts | 10 + src/common/utils/normalize-search-response.ts | 58 +++ src/common/utils/paginate.ts | 107 +++++ src/concepts/interfaces/concept.ts | 3 + src/hierarchy/hierarchy.ts | 66 +++ src/hierarchy/interfaces/ancestors-options.ts | 11 + .../interfaces/descendants-options.ts | 12 + .../interfaces/get-hierarchy-options.ts | 11 + src/hierarchy/interfaces/hierarchy.ts | 55 +++ src/hierarchy/interfaces/index.ts | 14 + src/index.ts | 64 +++ .../interfaces/get-mappings-options.ts | 7 + src/mappings/interfaces/index.ts | 12 + .../interfaces/map-concepts-options.ts | 28 ++ src/mappings/interfaces/mapping.ts | 65 +++ src/mappings/mappings.ts | 68 +++ .../interfaces/get-relationships-options.ts | 19 + src/relationships/interfaces/index.ts | 8 + .../list-relationship-types-options.ts | 3 + src/relationships/interfaces/relationship.ts | 31 ++ src/relationships/relationships.ts | 47 ++ .../interfaces/advanced-search-options.ts | 17 + src/search/interfaces/autocomplete-options.ts | 6 + src/search/interfaces/basic-search-options.ts | 16 + src/search/interfaces/bulk-basic-options.ts | 6 + src/search/interfaces/bulk-search.ts | 64 +++ .../interfaces/bulk-semantic-options.ts | 10 + src/search/interfaces/index.ts | 34 ++ src/search/interfaces/paginate-options.ts | 9 + src/search/interfaces/search-result.ts | 35 ++ .../interfaces/semantic-search-options.ts | 10 + .../interfaces/semantic-search-result.ts | 21 + .../interfaces/similar-search-options.ts | 30 ++ .../interfaces/similar-search-result.ts | 29 ++ src/search/search.ts | 335 +++++++++++++ .../utils/normalize-search-response.test.ts | 60 +++ test/common/utils/paginate.test.ts | 121 +++++ test/hierarchy/hierarchy.test.ts | 84 ++++ test/mappings/mappings.test.ts | 126 +++++ test/relationships/relationships.test.ts | 66 +++ test/search/search.test.ts | 440 ++++++++++++++++++ 43 files changed, 2239 insertions(+) create mode 100644 src/common/utils/normalize-search-response.ts create mode 100644 src/common/utils/paginate.ts create mode 100644 src/hierarchy/hierarchy.ts create mode 100644 src/hierarchy/interfaces/ancestors-options.ts create mode 100644 src/hierarchy/interfaces/descendants-options.ts create mode 100644 src/hierarchy/interfaces/get-hierarchy-options.ts create mode 100644 src/hierarchy/interfaces/hierarchy.ts create mode 100644 src/hierarchy/interfaces/index.ts create mode 100644 src/mappings/interfaces/get-mappings-options.ts create mode 100644 src/mappings/interfaces/index.ts create mode 100644 src/mappings/interfaces/map-concepts-options.ts create mode 100644 src/mappings/interfaces/mapping.ts create mode 100644 src/mappings/mappings.ts create mode 100644 src/relationships/interfaces/get-relationships-options.ts create mode 100644 src/relationships/interfaces/index.ts create mode 100644 src/relationships/interfaces/list-relationship-types-options.ts create mode 100644 src/relationships/interfaces/relationship.ts create mode 100644 src/relationships/relationships.ts create mode 100644 src/search/interfaces/advanced-search-options.ts create mode 100644 src/search/interfaces/autocomplete-options.ts create mode 100644 src/search/interfaces/basic-search-options.ts create mode 100644 src/search/interfaces/bulk-basic-options.ts create mode 100644 src/search/interfaces/bulk-search.ts create mode 100644 src/search/interfaces/bulk-semantic-options.ts create mode 100644 src/search/interfaces/index.ts create mode 100644 src/search/interfaces/paginate-options.ts create mode 100644 src/search/interfaces/search-result.ts create mode 100644 src/search/interfaces/semantic-search-options.ts create mode 100644 src/search/interfaces/semantic-search-result.ts create mode 100644 src/search/interfaces/similar-search-options.ts create mode 100644 src/search/interfaces/similar-search-result.ts create mode 100644 src/search/search.ts create mode 100644 test/common/utils/normalize-search-response.test.ts create mode 100644 test/common/utils/paginate.test.ts create mode 100644 test/hierarchy/hierarchy.test.ts create mode 100644 test/mappings/mappings.test.ts create mode 100644 test/relationships/relationships.test.ts create mode 100644 test/search/search.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cab8244..ddecf15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ All notable changes to this project will be documented in this file. The format - `client.concepts` — 7 methods: `get`, `getByCode`, `batch`, `suggest`, `related`, `relationships`, `recommended`. `concepts.get(0)` accepts the OMOP unmapped sentinel (R-SDK bug fix). `batch` validates 1–100 IDs synthetically; `recommended` validates `conceptIds` ≤ 100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. - `client.vocabularies` extended with 6 methods: `get`, `stats`, `domainStats`, `domains` (vocab-scoped), `conceptClasses`, `concepts`. - `client.domains` — 2 methods: `list`, `concepts`. +- `client.search` — 11 methods: `basic`, `basicIter`, `basicAll`, `advanced`, `autocomplete`, `semantic`, `semanticIter`, `semanticAll`, `bulkBasic`, `bulkSemantic`, `similar`. `bulkBasic` validates 1–50 searches; `bulkSemantic` validates 1–25; `similar` enforces XOR of `conceptId`/`conceptName`/`query` both at the TS type level (discriminated union) and at runtime. +- `client.hierarchy` — 3 methods: `get` (flat or graph format), `ancestors`, `descendants`. Server caps `maxLevels` at 20. +- `client.relationships` — 2 methods: `get` (shares wire endpoint with `concepts.relationships` — kept as parallel discoverable surface), `types`. +- `client.mappings` — 2 methods: `get` and `map`. `map` enforces XOR of `sourceConcepts` vs `sourceCodes` at both type and runtime levels. `vocabRelease` is routed to the `?vocab_release=` query parameter rather than the JSON body (matches Python SDK convention). JSDoc documents the Procedure-domain vocabulary priority chain (SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → ICD9Proc → OPCS4 → OMOP Extension). +- `ConceptHierarchyNode` extended with `domain_id`, `concept_class_id`, `standard_concept` optional fields — now matches Python's `HierarchyConcept` and is re-exported as `HierarchyConcept`/`Ancestor`/`Descendant` from the hierarchy module. +- `ConceptRelationship` re-exported as `Relationship` from the relationships module — kept in sync via type alias. +- `basic` and `advanced` normalise three known server response shapes (`{ concepts, facets, search_metadata }`, legacy `{ data: [...] }`, bare `Concept[]`) into a stable `SearchResult` — addresses the cross-SDK shape drift documented in `project_search_api_response_shapes.md`. +- `similar` uses a two-arg `(options, requestOptions)` signature (not the merged style) because its `query` XOR field would otherwise collide with `PerCallOptions.query`. +- `paginate()` async generator and `paginateAll()` eager collector in `common/utils/` — generic page-walking helpers used by the search `*Iter` / `*All` variants. The async generator throws `OMOPHubIteratorError` on page failure; `paginateAll` accumulates errors as values. - `syntheticError(name, message, details?)` helper in `common/utils/` — builds wire-shaped errors for client-side validation without issuing a network call. diff --git a/src/client.ts b/src/client.ts index 4365729..7830284 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,7 +14,11 @@ import { unwrapEnvelope } from './common/utils/unwrap-envelope.js'; import { Concepts } from './concepts/concepts.js'; import { Domains } from './domains/domains.js'; import { OMOPHubError } from './errors.js'; +import { Hierarchy } from './hierarchy/hierarchy.js'; import type { Response as OMOPHubResponse } from './interfaces.js'; +import { Mappings } from './mappings/mappings.js'; +import { Relationships } from './relationships/relationships.js'; +import { Search } from './search/search.js'; import { __version__ } from './version.js'; import { Vocabularies } from './vocabularies/vocabularies.js'; @@ -65,6 +69,10 @@ export class OMOPHub { readonly concepts: Concepts; readonly domains: Domains; + readonly hierarchy: Hierarchy; + readonly mappings: Mappings; + readonly relationships: Relationships; + readonly search: Search; readonly vocabularies: Vocabularies; constructor(apiKey?: string, options: OMOPHubOptions = {}) { @@ -94,6 +102,10 @@ export class OMOPHub { this.concepts = new Concepts(this); this.domains = new Domains(this); + this.hierarchy = new Hierarchy(this); + this.mappings = new Mappings(this); + this.relationships = new Relationships(this); + this.search = new Search(this); this.vocabularies = new Vocabularies(this); } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 4b51377..c75157e 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -2,6 +2,16 @@ export { backoffMs, isRetryableStatus } from './backoff.js'; export { appendQuery, buildQuery } from './build-query.js'; export { envOr, envOrUndefined } from './env.js'; export { mergeHeaders } from './merge-headers.js'; +export { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from './normalize-search-response.js'; +export { + type PageFetcher, + type PaginateAllResult, + paginate, + paginateAll, +} from './paginate.js'; export { connectionError, parseErrorResponse, timeoutError } from './parse-error.js'; export { sleep } from './sleep.js'; export { syntheticError } from './synthetic-error.js'; diff --git a/src/common/utils/normalize-search-response.ts b/src/common/utils/normalize-search-response.ts new file mode 100644 index 0000000..5b3667e --- /dev/null +++ b/src/common/utils/normalize-search-response.ts @@ -0,0 +1,58 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; +import type { + SearchFacets, + SearchMetadata, + SearchResult, +} from '../../search/interfaces/search-result.js'; +import type { SemanticSearchResult } from '../../search/interfaces/semantic-search-result.js'; + +/** + * Normalises the wire shape of `GET /search/concepts` and + * `POST /search/advanced` into a stable `SearchResult` object. + * + * The server historically returned several shapes for the inner `data` + * payload (post-envelope-unwrap): + * 1. `{ concepts: Concept[], facets, search_metadata }` — modern. + * 2. `{ data: Concept[] }` — legacy paginated wrapper. + * 3. `Concept[]` — bare array. + * + * Callers always see shape #1 regardless of what the server sent. + * + * @see project memory `project_search_api_response_shapes.md` + */ +export function normaliseBasicSearchData(raw: unknown): SearchResult { + if (Array.isArray(raw)) { + return { concepts: raw as Concept[] }; + } + if (!raw || typeof raw !== 'object') { + return { concepts: [] }; + } + const obj = raw as Record; + const concepts = pickArray(obj.concepts) ?? pickArray(obj.data) ?? []; + const result: SearchResult = { concepts }; + if (obj.facets && typeof obj.facets === 'object') { + result.facets = obj.facets as SearchFacets; + } + if (obj.search_metadata && typeof obj.search_metadata === 'object') { + result.search_metadata = obj.search_metadata as SearchMetadata; + } + return result; +} + +/** + * Normalises `GET /concepts/semantic-search` into a flat + * `SemanticSearchResult[]` for the iter/all helpers. Handles both + * `{ results: [...] }` and bare-array forms. + */ +export function normaliseSemanticSearchData(raw: unknown): SemanticSearchResult[] { + if (Array.isArray(raw)) return raw as SemanticSearchResult[]; + if (!raw || typeof raw !== 'object') return []; + const obj = raw as Record; + return ( + pickArray(obj.results) ?? pickArray(obj.data) ?? [] + ); +} + +function pickArray(value: unknown): T[] | null { + return Array.isArray(value) ? (value as T[]) : null; +} diff --git a/src/common/utils/paginate.ts b/src/common/utils/paginate.ts new file mode 100644 index 0000000..36564e7 --- /dev/null +++ b/src/common/utils/paginate.ts @@ -0,0 +1,107 @@ +import { OMOPHubIteratorError } from '../../errors.js'; +import type { ErrorResponse, Response as OMOPHubResponse } from '../../interfaces.js'; +import type { PaginatedData } from '../interfaces/pagination.js'; + +/** + * A page-fetching closure. Resource methods adapt their own typed + * options into this generic shape so `paginate` / `paginateAll` stay + * resource-agnostic. + * + * The returned envelope may be a `PaginatedData` (preferred — gives us + * `has_next`) OR a bare `T[]` (legacy / non-paginated endpoints — stop + * after one page). + */ +export type PageFetcher = ( + page: number, + pageSize: number, +) => Promise | T[]>>; + +export interface PaginateAllResult { + data: T[]; + errors: ErrorResponse[]; + pagesFetched: number; +} + +/** + * Async generator that yields each item across pages. + * + * Throws `OMOPHubIteratorError` (wrapping the page's `ErrorResponse`) + * the first time a page fetch fails — async generators can't gracefully + * yield a discriminated `{ data, error }` union, so they throw and the + * eager `paginateAll` variant accumulates errors as values for callers + * who prefer that style. + */ +export async function* paginate( + fetchPage: PageFetcher, + options: { startPage?: number; pageSize?: number; maxPages?: number } = {}, +): AsyncGenerator { + const startPage = options.startPage ?? 1; + const pageSize = options.pageSize ?? 100; + const maxPages = options.maxPages ?? Number.POSITIVE_INFINITY; + + let page = startPage; + let pagesYielded = 0; + while (pagesYielded < maxPages) { + const response = await fetchPage(page, pageSize); + if (response.error) { + throw new OMOPHubIteratorError( + response.error.message, + response.error.statusCode, + response.error.name, + ); + } + const items = extractItems(response.data); + if (items.length === 0) return; + for (const item of items) yield item; + pagesYielded++; + + if (!hasNextPage(response.data)) return; + page++; + } +} + +/** + * Eagerly walks every page, collecting items into an array. Errors are + * accumulated rather than thrown — caller decides what to do with a + * partial result. + */ +export async function paginateAll( + fetchPage: PageFetcher, + options: { startPage?: number; pageSize?: number; maxPages?: number } = {}, +): Promise> { + const startPage = options.startPage ?? 1; + const pageSize = options.pageSize ?? 100; + const maxPages = options.maxPages ?? Number.POSITIVE_INFINITY; + + const collected: T[] = []; + const errors: ErrorResponse[] = []; + let pagesFetched = 0; + let page = startPage; + + while (pagesFetched < maxPages) { + const response = await fetchPage(page, pageSize); + pagesFetched++; + if (response.error) { + errors.push(response.error); + return { data: collected, errors, pagesFetched }; + } + const items = extractItems(response.data); + collected.push(...items); + if (items.length === 0 || !hasNextPage(response.data)) { + return { data: collected, errors, pagesFetched }; + } + page++; + } + return { data: collected, errors, pagesFetched }; +} + +function extractItems(data: PaginatedData | T[] | null): T[] { + if (data === null) return []; + if (Array.isArray(data)) return data; + return data.data; +} + +function hasNextPage(data: PaginatedData | T[] | null): boolean { + if (!data || Array.isArray(data)) return false; + return data.meta.pagination.has_next === true; +} diff --git a/src/concepts/interfaces/concept.ts b/src/concepts/interfaces/concept.ts index 17983a9..096c8b4 100644 --- a/src/concepts/interfaces/concept.ts +++ b/src/concepts/interfaces/concept.ts @@ -38,6 +38,9 @@ export interface ConceptHierarchyNode { concept_name: string; vocabulary_id: string; concept_code: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; level?: number; min_levels_of_separation?: number; max_levels_of_separation?: number; diff --git a/src/hierarchy/hierarchy.ts b/src/hierarchy/hierarchy.ts new file mode 100644 index 0000000..bd9f41b --- /dev/null +++ b/src/hierarchy/hierarchy.ts @@ -0,0 +1,66 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { AncestorsOptions } from './interfaces/ancestors-options.js'; +import type { DescendantsOptions } from './interfaces/descendants-options.js'; +import type { GetHierarchyOptions } from './interfaces/get-hierarchy-options.js'; +import type { + AncestorsResult, + DescendantsResult, + HierarchyResult, +} from './interfaces/hierarchy.js'; + +export class Hierarchy { + constructor(private readonly client: OMOPHub) {} + + /** + * Fetch the concept hierarchy in either flat or graph form. + * + * The `format` option drives the response shape — `'flat'` returns + * `concepts` + `paths`, `'graph'` returns `nodes` + `edges`. The + * server caps `maxLevels` at 20 regardless of what's sent. + * + * @see https://docs.omophub.com/api-reference/hierarchy/get + */ + async get( + conceptId: number, + options: GetHierarchyOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/hierarchy`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List a concept's ancestors (concepts higher up in the hierarchy). + */ + async ancestors( + conceptId: number, + options: AncestorsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/ancestors`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List a concept's descendants (concepts lower in the hierarchy). + */ + async descendants( + conceptId: number, + options: DescendantsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/descendants`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } +} diff --git a/src/hierarchy/interfaces/ancestors-options.ts b/src/hierarchy/interfaces/ancestors-options.ts new file mode 100644 index 0000000..ae9eab6 --- /dev/null +++ b/src/hierarchy/interfaces/ancestors-options.ts @@ -0,0 +1,11 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface AncestorsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** Max distance from the source concept. Server-side cap applies. */ + maxLevels?: number; + relationshipTypes?: string[]; + includePaths?: boolean; + includeDistance?: boolean; + includeInvalid?: boolean; +} diff --git a/src/hierarchy/interfaces/descendants-options.ts b/src/hierarchy/interfaces/descendants-options.ts new file mode 100644 index 0000000..8c12f60 --- /dev/null +++ b/src/hierarchy/interfaces/descendants-options.ts @@ -0,0 +1,12 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface DescendantsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** Max descent depth. Capped at 20 by the server. Default 10. */ + maxLevels?: number; + relationshipTypes?: string[]; + includeDistance?: boolean; + includePaths?: boolean; + includeInvalid?: boolean; + domainIds?: string[]; +} diff --git a/src/hierarchy/interfaces/get-hierarchy-options.ts b/src/hierarchy/interfaces/get-hierarchy-options.ts new file mode 100644 index 0000000..595c383 --- /dev/null +++ b/src/hierarchy/interfaces/get-hierarchy-options.ts @@ -0,0 +1,11 @@ +export interface GetHierarchyOptions { + /** `'flat'` (default) returns concepts + paths; `'graph'` returns nodes + edges. */ + format?: 'flat' | 'graph'; + vocabularyIds?: string[]; + domainIds?: string[]; + /** Max traversal depth. Capped at 20 by the server. Default 10. */ + maxLevels?: number; + maxResults?: number; + relationshipTypes?: string[]; + includeInvalid?: boolean; +} diff --git a/src/hierarchy/interfaces/hierarchy.ts b/src/hierarchy/interfaces/hierarchy.ts new file mode 100644 index 0000000..3c789b8 --- /dev/null +++ b/src/hierarchy/interfaces/hierarchy.ts @@ -0,0 +1,55 @@ +import type { ConceptHierarchyNode } from '../../concepts/interfaces/concept.js'; + +/** + * Hierarchy node — same shape as `ConceptHierarchyNode` from the concepts + * module. Aliased so users reading hierarchy code see the canonical name. + */ +export type HierarchyConcept = ConceptHierarchyNode; +export type Ancestor = ConceptHierarchyNode; +export type Descendant = ConceptHierarchyNode; + +export interface HierarchyPath { + path: number[]; + concepts?: ConceptHierarchyNode[]; + length?: number; +} + +export interface HierarchySummary { + total_ancestors: number; + total_descendants: number; + max_hierarchy_depth: number; + unique_vocabularies: number; + relationship_types_used: string[]; + classification_count: number; +} + +export interface HierarchyEdge { + source: number; + target: number; + relationship_id: string; +} + +/** + * Response from `GET /concepts/{id}/hierarchy`. The exact shape depends on + * the `format` request parameter — `'flat'` returns `concepts` + paths, + * `'graph'` returns `nodes` + `edges`. We type both for ergonomics; the + * caller knows which they asked for. + */ +export interface HierarchyResult { + format?: 'flat' | 'graph'; + concepts?: ConceptHierarchyNode[]; + paths?: HierarchyPath[]; + nodes?: ConceptHierarchyNode[]; + edges?: HierarchyEdge[]; + summary?: HierarchySummary; +} + +export interface AncestorsResult { + ancestors: Ancestor[]; + summary?: HierarchySummary; +} + +export interface DescendantsResult { + descendants: Descendant[]; + summary?: HierarchySummary; +} diff --git a/src/hierarchy/interfaces/index.ts b/src/hierarchy/interfaces/index.ts new file mode 100644 index 0000000..e229d3b --- /dev/null +++ b/src/hierarchy/interfaces/index.ts @@ -0,0 +1,14 @@ +export type { AncestorsOptions } from './ancestors-options.js'; +export type { DescendantsOptions } from './descendants-options.js'; +export type { GetHierarchyOptions } from './get-hierarchy-options.js'; +export type { + Ancestor, + AncestorsResult, + Descendant, + DescendantsResult, + HierarchyConcept, + HierarchyEdge, + HierarchyPath, + HierarchyResult, + HierarchySummary, +} from './hierarchy.js'; diff --git a/src/index.ts b/src/index.ts index 7450fce..3102789 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,12 +47,76 @@ export type { ListDomainsOptions, } from './domains/interfaces/index.js'; export { OMOPHubError, OMOPHubIteratorError } from './errors.js'; +export type { + Ancestor, + AncestorsOptions, + AncestorsResult, + Descendant, + DescendantsOptions, + DescendantsResult, + GetHierarchyOptions, + HierarchyConcept, + HierarchyEdge, + HierarchyPath, + HierarchyResult, + HierarchySummary, +} from './hierarchy/interfaces/index.js'; export type { ErrorResponse, OMOPHUB_ERROR_CODE_KEY, Response, ResponseMeta, } from './interfaces.js'; +export type { + FailedMapping, + GetMappingsOptions, + MapConceptsOptions, + MapConceptsResult, + Mapping, + MappingContext, + MappingQuality, + MappingsListResult, + MappingsSummary, + SourceCodeRef, +} from './mappings/interfaces/index.js'; +export type { + GetRelationshipsOptions, + ListRelationshipTypesOptions, + Relationship, + RelationshipsResult, + RelationshipType, + RelationshipTypesResult, +} from './relationships/interfaces/index.js'; +export type { + AdvancedSearchOptions, + AutocompleteOptions, + BasicSearchOptions, + BulkBasicOptions, + BulkBasicResultItem, + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSearchDefaults, + BulkSearchStatus, + BulkSemanticDefaults, + BulkSemanticOptions, + BulkSemanticResultItem, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, + PaginateOptions, + RelationshipFilter, + SearchFacet, + SearchFacets, + SearchMetadata, + SearchResult, + SemanticSearchMetadata, + SemanticSearchOptions, + SemanticSearchResult, + SemanticSearchResultSet, + SimilarConcept, + SimilarSearchMetadata, + SimilarSearchOptions, + SimilarSearchResult, +} from './search/interfaces/index.js'; export { __version__ } from './version.js'; export type { ConceptClass, diff --git a/src/mappings/interfaces/get-mappings-options.ts b/src/mappings/interfaces/get-mappings-options.ts new file mode 100644 index 0000000..14181f2 --- /dev/null +++ b/src/mappings/interfaces/get-mappings-options.ts @@ -0,0 +1,7 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface GetMappingsOptions extends VocabReleaseMixin { + /** Restrict mappings to a single target vocabulary (e.g. `'SNOMED'`). */ + targetVocabulary?: string; + includeInvalid?: boolean; +} diff --git a/src/mappings/interfaces/index.ts b/src/mappings/interfaces/index.ts new file mode 100644 index 0000000..f112612 --- /dev/null +++ b/src/mappings/interfaces/index.ts @@ -0,0 +1,12 @@ +export type { GetMappingsOptions } from './get-mappings-options.js'; +export type { MapConceptsOptions } from './map-concepts-options.js'; +export type { + FailedMapping, + MapConceptsResult, + Mapping, + MappingContext, + MappingQuality, + MappingsListResult, + MappingsSummary, + SourceCodeRef, +} from './mapping.js'; diff --git a/src/mappings/interfaces/map-concepts-options.ts b/src/mappings/interfaces/map-concepts-options.ts new file mode 100644 index 0000000..0ec385c --- /dev/null +++ b/src/mappings/interfaces/map-concepts-options.ts @@ -0,0 +1,28 @@ +import type { SourceCodeRef } from './mapping.js'; + +interface MapConceptsBase { + /** Target vocabulary ID (e.g. `'SNOMED'`, `'RxNorm'`). Required. */ + targetVocabulary: string; + mappingType?: 'direct' | 'equivalent' | 'broader' | 'narrower'; + includeInvalid?: boolean; + /** + * Vocabulary release pin. Sent as a `?vocab_release=` query-string + * parameter (NOT in the JSON body) — matches the Python SDK convention. + */ + vocabRelease?: string; +} + +/** + * `mappings.map` accepts exactly one of two input shapes: + * - `sourceConcepts`: numeric OMOP concept IDs to map. + * - `sourceCodes`: native-vocabulary `(vocabulary_id, concept_code)` pairs. + * + * Enforced by the discriminated union at the type level; defended at + * runtime in `mappings.map()` so JS callers / `as any` users get a + * structured `missing_required_field` error rather than a wire 400. + */ +export type MapConceptsOptions = MapConceptsBase & + ( + | { sourceConcepts: number[]; sourceCodes?: never } + | { sourceConcepts?: never; sourceCodes: SourceCodeRef[] } + ); diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts new file mode 100644 index 0000000..59eef33 --- /dev/null +++ b/src/mappings/interfaces/mapping.ts @@ -0,0 +1,65 @@ +export interface MappingQuality { + confidence_score: number; + equivalence_type?: string; + semantic_similarity?: number; + mapping_source?: string; + validation_status?: string; + last_reviewed_date?: string; +} + +export interface MappingContext { + source_table?: string; + target_table?: string; + scope?: string; +} + +export interface Mapping { + source_concept_id: number; + source_concept_name: string; + source_vocabulary_id: string; + source_concept_code: string; + target_concept_id: number; + target_concept_name: string; + target_vocabulary_id: string; + target_concept_code: string; + target_domain_id?: string; + target_concept_class_id?: string; + mapping_type: string; + relationship_id?: string; + invalid_reason?: string | null; + quality?: MappingQuality; + context?: MappingContext; +} + +export interface MappingsSummary { + total_source_concepts?: number; + total_mappings?: number; + mapped_concepts?: number; + unmapped_concepts?: number; +} + +export interface MappingsListResult { + mappings: Mapping[]; + summary?: MappingsSummary; +} + +export interface FailedMapping { + source_concept_id?: number; + source_code?: { vocabulary_id: string; concept_code: string }; + reason?: string; +} + +export interface MapConceptsResult { + mappings: Mapping[]; + failed_mappings?: FailedMapping[]; + summary?: MappingsSummary; +} + +/** + * Reference to a non-standard concept by its vocabulary code, used as + * input to `mappings.map({ sourceCodes: [...] })`. + */ +export interface SourceCodeRef { + vocabulary_id: string; + concept_code: string; +} diff --git a/src/mappings/mappings.ts b/src/mappings/mappings.ts new file mode 100644 index 0000000..1b26564 --- /dev/null +++ b/src/mappings/mappings.ts @@ -0,0 +1,68 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { GetMappingsOptions } from './interfaces/get-mappings-options.js'; +import type { MapConceptsOptions } from './interfaces/map-concepts-options.js'; +import type { MapConceptsResult, MappingsListResult } from './interfaces/mapping.js'; + +export class Mappings { + constructor(private readonly client: OMOPHub) {} + + /** + * List the mappings already defined for a single concept. + * + * @see https://docs.omophub.com/api-reference/mappings/get + */ + async get( + conceptId: number, + options: GetMappingsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/mappings`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Map a batch of source concepts (or vocabulary codes) to a target + * vocabulary. + * + * Exactly one of `sourceConcepts` or `sourceCodes` must be supplied — + * enforced by the `MapConceptsOptions` discriminated union and + * re-validated at runtime so JS callers also get a structured error. + * + * `vocabRelease` is sent as a query-string parameter (NOT in the JSON + * body) — matches the Python SDK convention. + * + * **Procedure mappings:** for Procedure-domain sources the API applies + * a fallback vocabulary priority when `targetVocabulary` is left to + * "best fit" semantics: SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → + * ICD9Proc → OPCS4 → OMOP Extension. + */ + async map( + options: MapConceptsOptions & PostOptions, + ): Promise> { + const hasConcepts = Array.isArray(options.sourceConcepts); + const hasCodes = Array.isArray(options.sourceCodes); + if (hasConcepts === hasCodes) { + return syntheticError( + 'missing_required_field', + 'Provide exactly one of `sourceConcepts` or `sourceCodes`.', + ); + } + + const { vocabRelease, signal, headers, query, idempotencyKey, ...bodyFields } = options; + const body = toSnakeCaseKeys(bodyFields); + return this.client.post('/concepts/map', body, { + signal, + headers, + query: { vocabRelease, ...query }, + idempotencyKey, + }); + } +} diff --git a/src/relationships/interfaces/get-relationships-options.ts b/src/relationships/interfaces/get-relationships-options.ts new file mode 100644 index 0000000..43f929f --- /dev/null +++ b/src/relationships/interfaces/get-relationships-options.ts @@ -0,0 +1,19 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Options for `relationships.get(conceptId, ...)`. + * + * Shares the wire endpoint with `concepts.relationships(conceptId, ...)` — + * the two methods are kept as parallel entry points so users who think + * concept-first or relationship-first both have a discoverable surface. + * Keep field lists in sync with `ConceptRelationshipsOptions`. + */ +export interface GetRelationshipsOptions extends PaginationOptions, VocabReleaseMixin { + relationshipIds?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + includeInvalid?: boolean; + standardOnly?: boolean; + includeReverse?: boolean; +} diff --git a/src/relationships/interfaces/index.ts b/src/relationships/interfaces/index.ts new file mode 100644 index 0000000..b484ea3 --- /dev/null +++ b/src/relationships/interfaces/index.ts @@ -0,0 +1,8 @@ +export type { GetRelationshipsOptions } from './get-relationships-options.js'; +export type { ListRelationshipTypesOptions } from './list-relationship-types-options.js'; +export type { + Relationship, + RelationshipsResult, + RelationshipType, + RelationshipTypesResult, +} from './relationship.js'; diff --git a/src/relationships/interfaces/list-relationship-types-options.ts b/src/relationships/interfaces/list-relationship-types-options.ts new file mode 100644 index 0000000..4cc2402 --- /dev/null +++ b/src/relationships/interfaces/list-relationship-types-options.ts @@ -0,0 +1,3 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export type ListRelationshipTypesOptions = PaginationOptions; diff --git a/src/relationships/interfaces/relationship.ts b/src/relationships/interfaces/relationship.ts new file mode 100644 index 0000000..5b66a70 --- /dev/null +++ b/src/relationships/interfaces/relationship.ts @@ -0,0 +1,31 @@ +import type { ConceptRelationship } from '../../concepts/interfaces/concept.js'; + +/** + * Per-concept relationship entry. Aliased from `ConceptRelationship` + * (defined in concepts/interfaces/) so users reading relationship code + * see the canonical name and the two stay in sync. + */ +export type Relationship = ConceptRelationship; + +/** + * Metadata about a relationship type from `GET /relationships/types`. + */ +export interface RelationshipType { + relationship_id: string; + relationship_name: string; + is_hierarchical: boolean; + is_defining: boolean; + is_symmetric: boolean; + is_transitive: boolean; + reverse_relationship_id?: string; + primary_vocabularies?: string[]; + defines_ancestry?: boolean; +} + +export interface RelationshipsResult { + relationships: Relationship[]; +} + +export interface RelationshipTypesResult { + relationship_types: RelationshipType[]; +} diff --git a/src/relationships/relationships.ts b/src/relationships/relationships.ts new file mode 100644 index 0000000..34b6be1 --- /dev/null +++ b/src/relationships/relationships.ts @@ -0,0 +1,47 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { GetRelationshipsOptions } from './interfaces/get-relationships-options.js'; +import type { ListRelationshipTypesOptions } from './interfaces/list-relationship-types-options.js'; +import type { RelationshipsResult, RelationshipTypesResult } from './interfaces/relationship.js'; + +export class Relationships { + constructor(private readonly client: OMOPHub) {} + + /** + * List a concept's relationships. + * + * Hits the same wire endpoint as `client.concepts.relationships(...)` — + * kept as a separate method so callers thinking in relationship-first + * terms have a discoverable surface. Keep parameter lists in sync. + * + * @see https://docs.omophub.com/api-reference/relationships/get + */ + async get( + conceptId: number, + options: GetRelationshipsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/relationships`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List the catalog of available relationship types. + * + * @see https://docs.omophub.com/api-reference/relationships/types + */ + async types( + options: ListRelationshipTypesOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get('/relationships/types', { + signal, + headers, + query: { ...flags, ...query }, + }); + } +} diff --git a/src/search/interfaces/advanced-search-options.ts b/src/search/interfaces/advanced-search-options.ts new file mode 100644 index 0000000..dd75709 --- /dev/null +++ b/src/search/interfaces/advanced-search-options.ts @@ -0,0 +1,17 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface RelationshipFilter { + relationship_id: string; + target_concept_id?: number; + direction?: 'forward' | 'reverse'; +} + +export interface AdvancedSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + conceptClassIds?: string[]; + standardConceptsOnly?: boolean; + includeInvalid?: boolean; + /** Restrict matches to concepts with these specific relationships. */ + relationshipFilters?: RelationshipFilter[]; +} diff --git a/src/search/interfaces/autocomplete-options.ts b/src/search/interfaces/autocomplete-options.ts new file mode 100644 index 0000000..960fdeb --- /dev/null +++ b/src/search/interfaces/autocomplete-options.ts @@ -0,0 +1,6 @@ +export interface AutocompleteOptions { + vocabularyIds?: string[]; + domains?: string[]; + /** Maximum number of suggestions. Default 10 at the API; max 100. */ + pageSize?: number; +} diff --git a/src/search/interfaces/basic-search-options.ts b/src/search/interfaces/basic-search-options.ts new file mode 100644 index 0000000..99190e4 --- /dev/null +++ b/src/search/interfaces/basic-search-options.ts @@ -0,0 +1,16 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface BasicSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + conceptClassIds?: string[]; + /** `'S'` standard, `'C'` classification, `'N'` non-standard. */ + standardConcept?: 'S' | 'C' | 'N'; + includeSynonyms?: boolean; + includeInvalid?: boolean; + /** Minimum relevance score (0–1). */ + minScore?: number; + exactMatch?: boolean; + sortBy?: 'relevance' | 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/search/interfaces/bulk-basic-options.ts b/src/search/interfaces/bulk-basic-options.ts new file mode 100644 index 0000000..e22242b --- /dev/null +++ b/src/search/interfaces/bulk-basic-options.ts @@ -0,0 +1,6 @@ +import type { BulkSearchDefaults } from './bulk-search.js'; + +export interface BulkBasicOptions { + /** Shared params applied to every search in the batch unless overridden. */ + defaults?: BulkSearchDefaults; +} diff --git a/src/search/interfaces/bulk-search.ts b/src/search/interfaces/bulk-search.ts new file mode 100644 index 0000000..a695cc5 --- /dev/null +++ b/src/search/interfaces/bulk-search.ts @@ -0,0 +1,64 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; +import type { SemanticSearchResult } from './semantic-search-result.js'; + +export interface BulkSearchDefaults { + vocabulary_ids?: string[]; + domain_ids?: string[]; + page_size?: number; +} + +export interface BulkBasicSearchInput { + /** Caller-supplied ID echoed back on the matching result for correlation. */ + search_id: string; + query: string; + vocabulary_ids?: string[]; + domain_ids?: string[]; + page_size?: number; +} + +export interface BulkSemanticSearchInput { + search_id: string; + query: string; + threshold?: number; + page_size?: number; + vocabulary_ids?: string[]; + domain_ids?: string[]; +} + +export type BulkSearchStatus = 'completed' | 'failed'; + +export interface BulkBasicResultItem { + search_id: string; + query: string; + status: BulkSearchStatus; + results: Concept[]; + error?: string; + duration?: number; +} + +export interface BulkBasicSearchResponse { + results: BulkBasicResultItem[]; + total_searches: number; + completed_searches: number; + failed_searches: number; +} + +export interface BulkSemanticResultItem { + search_id: string; + query: string; + status: BulkSearchStatus; + results: SemanticSearchResult[]; + error?: string; + duration?: number; +} + +export interface BulkSemanticSearchResponse { + results: BulkSemanticResultItem[]; + total_searches: number; + /** Note the naming difference vs `BulkBasicSearchResponse.completed_searches` + * — the server uses `completed_count`/`failed_count` for the semantic + * endpoint. Surfaced as-is to match the wire. */ + completed_count: number; + failed_count: number; + total_duration?: number; +} diff --git a/src/search/interfaces/bulk-semantic-options.ts b/src/search/interfaces/bulk-semantic-options.ts new file mode 100644 index 0000000..8d75e44 --- /dev/null +++ b/src/search/interfaces/bulk-semantic-options.ts @@ -0,0 +1,10 @@ +import type { BulkSearchDefaults } from './bulk-search.js'; + +export interface BulkSemanticDefaults extends BulkSearchDefaults { + threshold?: number; +} + +export interface BulkSemanticOptions { + /** Shared params applied to every search in the batch unless overridden. */ + defaults?: BulkSemanticDefaults; +} diff --git a/src/search/interfaces/index.ts b/src/search/interfaces/index.ts new file mode 100644 index 0000000..9888559 --- /dev/null +++ b/src/search/interfaces/index.ts @@ -0,0 +1,34 @@ +export type { AdvancedSearchOptions, RelationshipFilter } from './advanced-search-options.js'; +export type { AutocompleteOptions } from './autocomplete-options.js'; +export type { BasicSearchOptions } from './basic-search-options.js'; +export type { BulkBasicOptions } from './bulk-basic-options.js'; +export type { + BulkBasicResultItem, + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSearchDefaults, + BulkSearchStatus, + BulkSemanticResultItem, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, +} from './bulk-search.js'; +export type { BulkSemanticDefaults, BulkSemanticOptions } from './bulk-semantic-options.js'; +export type { PaginateOptions } from './paginate-options.js'; +export type { + SearchFacet, + SearchFacets, + SearchMetadata, + SearchResult, +} from './search-result.js'; +export type { SemanticSearchOptions } from './semantic-search-options.js'; +export type { + SemanticSearchMetadata, + SemanticSearchResult, + SemanticSearchResultSet, +} from './semantic-search-result.js'; +export type { SimilarSearchOptions } from './similar-search-options.js'; +export type { + SimilarConcept, + SimilarSearchMetadata, + SimilarSearchResult, +} from './similar-search-result.js'; diff --git a/src/search/interfaces/paginate-options.ts b/src/search/interfaces/paginate-options.ts new file mode 100644 index 0000000..fe404d7 --- /dev/null +++ b/src/search/interfaces/paginate-options.ts @@ -0,0 +1,9 @@ +/** + * Per-call knobs for the `*Iter` and `*All` paginating variants. + * - `pageSize`: items requested per page (defaults to 100 for the iter helpers). + * - `maxPages`: hard stop on pages fetched, regardless of `has_next`. + */ +export interface PaginateOptions { + pageSize?: number; + maxPages?: number; +} diff --git a/src/search/interfaces/search-result.ts b/src/search/interfaces/search-result.ts new file mode 100644 index 0000000..31ed98d --- /dev/null +++ b/src/search/interfaces/search-result.ts @@ -0,0 +1,35 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; + +/** + * A single facet bucket — e.g. "SNOMED: 1,243". + */ +export interface SearchFacet { + value: string; + count: number; + label?: string; +} + +export interface SearchFacets { + vocabularies?: SearchFacet[]; + domains?: SearchFacet[]; + concept_classes?: SearchFacet[]; +} + +export interface SearchMetadata { + query?: string; + total_results?: number; + processing_time_ms?: number; + query_enhanced?: boolean; + enhanced_query?: string; +} + +/** + * Canonical `search.basic` and `search.advanced` payload — normalised at + * the resource boundary so `concepts` is always a `Concept[]`, even when + * the legacy server form returned `{ data: [...] }` instead. + */ +export interface SearchResult { + concepts: Concept[]; + facets?: SearchFacets; + search_metadata?: SearchMetadata; +} diff --git a/src/search/interfaces/semantic-search-options.ts b/src/search/interfaces/semantic-search-options.ts new file mode 100644 index 0000000..21d4c1f --- /dev/null +++ b/src/search/interfaces/semantic-search-options.ts @@ -0,0 +1,10 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface SemanticSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + standardConcept?: 'S' | 'C' | 'N'; + conceptClassId?: string; + /** Similarity threshold (0–1). */ + threshold?: number; +} diff --git a/src/search/interfaces/semantic-search-result.ts b/src/search/interfaces/semantic-search-result.ts new file mode 100644 index 0000000..85f344b --- /dev/null +++ b/src/search/interfaces/semantic-search-result.ts @@ -0,0 +1,21 @@ +import type { ConceptSummary } from '../../concepts/interfaces/concept.js'; + +export interface SemanticSearchResult extends ConceptSummary { + similarity_score: number; + matched_text?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; +} + +export interface SemanticSearchMetadata { + query: string; + threshold?: number; + total_candidates?: number; + processing_time_ms?: number; +} + +export interface SemanticSearchResultSet { + results: SemanticSearchResult[]; + search_metadata?: SemanticSearchMetadata; +} diff --git a/src/search/interfaces/similar-search-options.ts b/src/search/interfaces/similar-search-options.ts new file mode 100644 index 0000000..6015cc1 --- /dev/null +++ b/src/search/interfaces/similar-search-options.ts @@ -0,0 +1,30 @@ +interface SimilarSearchBase { + /** `'semantic'`, `'lexical'`, or `'hybrid'` (default). */ + algorithm?: 'semantic' | 'lexical' | 'hybrid'; + /** Similarity floor (0–1). Default 0.7 at the API. */ + similarityThreshold?: number; + pageSize?: number; + vocabularyIds?: string[]; + domainIds?: string[]; + standardConcept?: 'S' | 'C' | 'N'; + includeInvalid?: boolean; + includeScores?: boolean; + includeExplanations?: boolean; +} + +/** + * Exactly one of `conceptId`, `conceptName`, or `query` must be supplied. + * + * Encoded as a discriminated union so TypeScript enforces the XOR at the + * call site. A runtime check in `search.similar()` defends against JS + * callers / `as any` users. + * + * Note: `query` here is the free-text search variant. Because of this it + * conflicts with `PerCallOptions.query` (the escape-hatch params record), + * which is why `search.similar()` uses a two-arg signature + * `similar(options, requestOptions)` rather than merged options. + */ +export type SimilarSearchOptions = + | (SimilarSearchBase & { conceptId: number; conceptName?: never; query?: never }) + | (SimilarSearchBase & { conceptId?: never; conceptName: string; query?: never }) + | (SimilarSearchBase & { conceptId?: never; conceptName?: never; query: string }); diff --git a/src/search/interfaces/similar-search-result.ts b/src/search/interfaces/similar-search-result.ts new file mode 100644 index 0000000..87eda01 --- /dev/null +++ b/src/search/interfaces/similar-search-result.ts @@ -0,0 +1,29 @@ +import type { ConceptSummary } from '../../concepts/interfaces/concept.js'; + +export interface SimilarConcept extends ConceptSummary { + similarity_score: number; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + scores?: { + semantic?: number; + lexical?: number; + hybrid?: number; + }; + explanation?: string; +} + +export interface SimilarSearchMetadata { + original_query: string; + algorithm_used: 'semantic' | 'lexical' | 'hybrid'; + similarity_threshold: number; + total_candidates: number; + results_returned: number; + processing_time_ms: number; + embedding_latency_ms?: number; +} + +export interface SimilarSearchResult { + similar_concepts: SimilarConcept[]; + search_metadata: SimilarSearchMetadata; +} diff --git a/src/search/search.ts b/src/search/search.ts new file mode 100644 index 0000000..61ee75d --- /dev/null +++ b/src/search/search.ts @@ -0,0 +1,335 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from '../common/utils/normalize-search-response.js'; +import { type PaginateAllResult, paginate, paginateAll } from '../common/utils/paginate.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Concept, ConceptSuggestion } from '../concepts/interfaces/concept.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { AdvancedSearchOptions } from './interfaces/advanced-search-options.js'; +import type { AutocompleteOptions } from './interfaces/autocomplete-options.js'; +import type { BasicSearchOptions } from './interfaces/basic-search-options.js'; +import type { BulkBasicOptions } from './interfaces/bulk-basic-options.js'; +import type { + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, +} from './interfaces/bulk-search.js'; +import type { BulkSemanticOptions } from './interfaces/bulk-semantic-options.js'; +import type { PaginateOptions } from './interfaces/paginate-options.js'; +import type { SearchResult } from './interfaces/search-result.js'; +import type { SemanticSearchOptions } from './interfaces/semantic-search-options.js'; +import type { + SemanticSearchResult, + SemanticSearchResultSet, +} from './interfaces/semantic-search-result.js'; +import type { SimilarSearchOptions } from './interfaces/similar-search-options.js'; +import type { SimilarSearchResult } from './interfaces/similar-search-result.js'; + +const ITER_DEFAULT_PAGE_SIZE = 100; + +export class Search { + constructor(private readonly client: OMOPHub) {} + + // ─── Basic keyword search ────────────────────────────────────────── + + /** + * Keyword search across concepts. + * + * Normalises the response so `data.concepts` is always a `Concept[]`, + * regardless of whether the server returned `{ concepts: [...] }`, + * `{ data: [...] }`, or a bare array. + */ + async basic( + query: string, + options: BasicSearchOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + const response = await this.client.get('/search/concepts', { + signal, + headers, + // positional `query` spread LAST so user-supplied options.query can't + // silently override the primary search term. + query: { ...flags, ...extraQuery, query }, + }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + return { + data: normaliseBasicSearchData(response.data), + error: null, + meta: response.meta, + headers: response.headers, + }; + } + + /** + * Async iterator that walks the basic-search pages, yielding one + * concept at a time. Throws `OMOPHubIteratorError` if any page fails. + */ + basicIter( + query: string, + options: BasicSearchOptions & GetOptions & PaginateOptions = {}, + ): AsyncGenerator { + const { maxPages, pageSize, ...rest } = options; + return paginate( + async (page, size) => { + const r = await this.basic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + return { + ...r, + data: { + data: r.data.concepts, + meta: { pagination: derivePagination(r, page, size, r.data.concepts.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + /** + * Eagerly collects every basic-search page into a single array of + * concepts. Errors are accumulated rather than thrown. + */ + async basicAll( + query: string, + options: BasicSearchOptions & GetOptions & PaginateOptions = {}, + ): Promise> { + const { maxPages, pageSize, ...rest } = options; + return paginateAll( + async (page, size) => { + const r = await this.basic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + return { + ...r, + data: { + data: r.data.concepts, + meta: { pagination: derivePagination(r, page, size, r.data.concepts.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + // ─── Advanced (POST) search ──────────────────────────────────────── + + /** + * Advanced search with relationship filters. POST body so complex + * filter graphs can be expressed without URL-length pressure. + */ + async advanced( + query: string, + options: AdvancedSearchOptions & PostOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, idempotencyKey, ...flags } = options; + const body = toSnakeCaseKeys({ query, ...flags }); + const response = await this.client.post('/search/advanced', body, { + signal, + headers, + query: extraQuery, + idempotencyKey, + }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + return { + data: normaliseBasicSearchData(response.data), + error: null, + meta: response.meta, + headers: response.headers, + }; + } + + // ─── Autocomplete ────────────────────────────────────────────────── + + /** + * Lightweight typeahead suggestions. + */ + async autocomplete( + query: string, + options: AutocompleteOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + return this.client.get('/search/suggest', { + signal, + headers, + query: { ...flags, ...extraQuery, query }, + }); + } + + // ─── Semantic search ─────────────────────────────────────────────── + + /** + * Semantic (embedding-based) search. + */ + async semantic( + query: string, + options: SemanticSearchOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + return this.client.get('/concepts/semantic-search', { + signal, + headers, + query: { ...flags, ...extraQuery, query }, + }); + } + + /** + * Async iterator over semantic-search results. + */ + semanticIter( + query: string, + options: SemanticSearchOptions & GetOptions & PaginateOptions = {}, + ): AsyncGenerator { + const { maxPages, pageSize, ...rest } = options; + return paginate( + async (page, size) => { + const r = await this.semantic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + const results = normaliseSemanticSearchData(r.data); + return { + ...r, + data: { + data: results, + meta: { pagination: derivePagination(r, page, size, results.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + /** + * Eagerly collect every semantic-search page. + */ + async semanticAll( + query: string, + options: SemanticSearchOptions & GetOptions & PaginateOptions = {}, + ): Promise> { + const { maxPages, pageSize, ...rest } = options; + return paginateAll( + async (page, size) => { + const r = await this.semantic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + const results = normaliseSemanticSearchData(r.data); + return { + ...r, + data: { + data: results, + meta: { pagination: derivePagination(r, page, size, results.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + // ─── Bulk endpoints ──────────────────────────────────────────────── + + /** + * Run up to 50 basic searches in a single request. Returns a result + * per search keyed by the caller-supplied `search_id`. + */ + async bulkBasic( + searches: BulkBasicSearchInput[], + options: BulkBasicOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(searches) || searches.length < 1 || searches.length > 50) { + return syntheticError( + 'validation_error', + '`searches` must be an array of 1–50 items.', + ); + } + const { signal, headers, query, idempotencyKey, defaults } = options; + const body = toSnakeCaseKeys({ searches, defaults }); + return this.client.post('/search/bulk', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Run up to 25 semantic searches in a single request. + */ + async bulkSemantic( + searches: BulkSemanticSearchInput[], + options: BulkSemanticOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(searches) || searches.length < 1 || searches.length > 25) { + return syntheticError( + 'validation_error', + '`searches` must be an array of 1–25 items.', + ); + } + const { signal, headers, query, idempotencyKey, defaults } = options; + const body = toSnakeCaseKeys({ searches, defaults }); + return this.client.post('/search/semantic-bulk', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + // ─── Similarity search ───────────────────────────────────────────── + + /** + * Similarity search by `conceptId`, `conceptName`, or `query`. Exactly + * one of the three must be supplied — TS enforces via the discriminated + * `SimilarSearchOptions` type; this method also defends at runtime so + * JS callers get a structured error rather than a wire 400. + * + * Uses a two-arg signature because `query` is one of the XOR fields + * and would otherwise collide with `PerCallOptions.query`. + */ + async similar( + options: SimilarSearchOptions, + requestOptions: PostOptions = {}, + ): Promise> { + const provided = [ + options.conceptId !== undefined, + options.conceptName !== undefined, + options.query !== undefined, + ].filter(Boolean).length; + if (provided !== 1) { + return syntheticError( + 'missing_required_field', + 'Provide exactly one of `conceptId`, `conceptName`, or `query`.', + ); + } + const body = toSnakeCaseKeys(options); + return this.client.post('/search/similar', body, requestOptions); + } +} + +/** + * Best-effort pagination metadata for endpoints that don't return one + * (e.g. semantic search). When `actualCount < pageSize` we infer + * end-of-results — same heuristic Python's `paginate_all()` uses. + */ +function derivePagination( + response: OMOPHubResponse, + page: number, + pageSize: number, + actualCount: number, +) { + const fromMeta = response.meta?.pagination; + if (fromMeta) return fromMeta; + return { + page, + page_size: pageSize, + total_items: actualCount, + total_pages: actualCount < pageSize ? page : page + 1, + has_next: actualCount >= pageSize, + has_previous: page > 1, + }; +} diff --git a/test/common/utils/normalize-search-response.test.ts b/test/common/utils/normalize-search-response.test.ts new file mode 100644 index 0000000..ab9eac0 --- /dev/null +++ b/test/common/utils/normalize-search-response.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; +import { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from '../../../src/common/utils/normalize-search-response.js'; + +describe('normaliseBasicSearchData', () => { + test('passes through modern { concepts, facets, search_metadata } shape', () => { + const raw = { + concepts: [{ concept_id: 1 }], + facets: { vocabularies: [] }, + search_metadata: { query: 'diabetes' }, + }; + expect(normaliseBasicSearchData(raw)).toEqual(raw); + }); + + test('unwraps legacy { data: [...] } shape into concepts', () => { + const raw = { data: [{ concept_id: 1 }, { concept_id: 2 }] }; + expect(normaliseBasicSearchData(raw)).toEqual({ + concepts: [{ concept_id: 1 }, { concept_id: 2 }], + }); + }); + + test('wraps a bare array into { concepts: [...] }', () => { + expect(normaliseBasicSearchData([{ concept_id: 1 }])).toEqual({ + concepts: [{ concept_id: 1 }], + }); + }); + + test('returns empty concepts for null / non-object / no arrays present', () => { + expect(normaliseBasicSearchData(null)).toEqual({ concepts: [] }); + expect(normaliseBasicSearchData('weird')).toEqual({ concepts: [] }); + expect(normaliseBasicSearchData({ unrelated: 1 })).toEqual({ concepts: [] }); + }); +}); + +describe('normaliseSemanticSearchData', () => { + test('extracts results array from { results: [...] } shape', () => { + const raw = { + results: [{ concept_id: 1, similarity_score: 0.9 }], + search_metadata: {}, + }; + expect(normaliseSemanticSearchData(raw)).toEqual([{ concept_id: 1, similarity_score: 0.9 }]); + }); + + test('passes through a bare array', () => { + const raw = [{ concept_id: 1, similarity_score: 0.9 }]; + expect(normaliseSemanticSearchData(raw)).toEqual(raw); + }); + + test('falls back to { data: [...] } if results missing', () => { + const raw = { data: [{ concept_id: 1, similarity_score: 0.9 }] }; + expect(normaliseSemanticSearchData(raw)).toEqual([{ concept_id: 1, similarity_score: 0.9 }]); + }); + + test('returns empty array for null / non-object / no arrays present', () => { + expect(normaliseSemanticSearchData(null)).toEqual([]); + expect(normaliseSemanticSearchData({})).toEqual([]); + }); +}); diff --git a/test/common/utils/paginate.test.ts b/test/common/utils/paginate.test.ts new file mode 100644 index 0000000..be27d16 --- /dev/null +++ b/test/common/utils/paginate.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test, vi } from 'vitest'; +import { paginate, paginateAll } from '../../../src/common/utils/paginate.js'; +import { OMOPHubIteratorError } from '../../../src/errors.js'; +import type { Response as OMOPHubResponse } from '../../../src/interfaces.js'; + +function successPage(items: T[], hasNext: boolean, page = 1, pageSize = 100) { + return Promise.resolve({ + data: { + data: items, + meta: { + pagination: { + page, + page_size: pageSize, + total_items: hasNext ? page * pageSize + 1 : items.length, + total_pages: hasNext ? page + 1 : page, + has_next: hasNext, + has_previous: page > 1, + }, + }, + }, + error: null, + meta: null, + headers: {}, + } satisfies OMOPHubResponse<{ + data: T[]; + meta: { pagination: import('../../../src/common/interfaces/pagination.js').PaginationMeta }; + }>); +} + +function errorPage(name: string, message: string, status: number | null = 500) { + return Promise.resolve({ + data: null, + error: { name: name as never, message, statusCode: status }, + meta: null, + headers: {}, + } as OMOPHubResponse); +} + +describe('paginate (async generator)', () => { + test('yields items across multiple pages until has_next === false', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1, 2], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage([3, 4], false, 2, s)); + + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 2 })) { + out.push(item); + } + expect(out).toEqual([1, 2, 3, 4]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('respects maxPages', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage([2], true, 2, s)) + .mockImplementationOnce((_p, s) => successPage([3], true, 3, s)); + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 1, maxPages: 2 })) { + out.push(item); + } + expect(out).toEqual([1, 2]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('throws OMOPHubIteratorError on page failure', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1, 2], true, 1, s)) + .mockImplementationOnce(() => errorPage('service_unavailable', 'down', 503)); + + const gen = paginate(fetchPage, { pageSize: 2 }); + const collected: number[] = []; + let caught: unknown; + try { + for await (const item of gen) collected.push(item); + } catch (e) { + caught = e; + } + expect(collected).toEqual([1, 2]); + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + expect((caught as OMOPHubIteratorError).code).toBe('service_unavailable'); + expect((caught as OMOPHubIteratorError).statusCode).toBe(503); + }); + + test('stops on empty page even when has_next is true', async () => { + const fetchPage = vi.fn().mockImplementationOnce((_p, s) => successPage([], true, 1, s)); + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 2 })) out.push(item); + expect(out).toEqual([]); + expect(fetchPage).toHaveBeenCalledTimes(1); + }); +}); + +describe('paginateAll (eager collect)', () => { + test('accumulates items across pages and returns errors as values', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage(['a', 'b'], true, 1, s)) + .mockImplementationOnce(() => errorPage('rate_limit_exceeded', 'slow down', 429)); + + const result = await paginateAll(fetchPage, { pageSize: 2 }); + expect(result.data).toEqual(['a', 'b']); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.name).toBe('rate_limit_exceeded'); + expect(result.pagesFetched).toBe(2); + }); + + test('walks every page when no errors', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage(['a'], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage(['b'], false, 2, s)); + const result = await paginateAll(fetchPage, { pageSize: 1 }); + expect(result.data).toEqual(['a', 'b']); + expect(result.errors).toEqual([]); + expect(result.pagesFetched).toBe(2); + }); +}); diff --git a/test/hierarchy/hierarchy.test.ts b/test/hierarchy/hierarchy.test.ts new file mode 100644 index 0000000..9759b6b --- /dev/null +++ b/test/hierarchy/hierarchy.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID, mockApiErrorBody } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.hierarchy.get', () => { + test('hits GET /concepts/{id}/hierarchy', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.get(DIABETES_CONCEPT_ID); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/201826/hierarchy'); + }); + + test('snake-cases format, vocabularyIds, maxLevels, relationshipTypes, includeInvalid', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { nodes: [], edges: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.get(DIABETES_CONCEPT_ID, { + format: 'graph', + vocabularyIds: ['SNOMED', 'ICD10CM'], + maxLevels: 5, + relationshipTypes: ['Is a'], + includeInvalid: false, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('format=graph'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('max_levels=5'); + expect(url).toContain('relationship_types=Is+a'); + expect(url).toContain('include_invalid=false'); + }); +}); + +describe('client.hierarchy.ancestors', () => { + test('hits GET /concepts/{id}/ancestors with pagination + flags', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { ancestors: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.ancestors(DIABETES_CONCEPT_ID, { + vocabularyIds: ['SNOMED'], + maxLevels: 10, + includePaths: true, + includeDistance: true, + page: 2, + pageSize: 50, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/ancestors'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('max_levels=10'); + expect(url).toContain('include_paths=true'); + expect(url).toContain('include_distance=true'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + }); +}); + +describe('client.hierarchy.descendants', () => { + test('hits GET /concepts/{id}/descendants', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { descendants: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.descendants(DIABETES_CONCEPT_ID, { + maxLevels: 3, + domainIds: ['Condition'], + includeDistance: false, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/descendants'); + expect(url).toContain('max_levels=3'); + expect(url).toContain('domain_ids=Condition'); + expect(url).toContain('include_distance=false'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.hierarchy.descendants(9_999_999); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); diff --git a/test/mappings/mappings.test.ts b/test/mappings/mappings.test.ts new file mode 100644 index 0000000..7df57ec --- /dev/null +++ b/test/mappings/mappings.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID, mockApiErrorBody } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.mappings.get', () => { + test('hits GET /concepts/{id}/mappings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.get(DIABETES_CONCEPT_ID, { + targetVocabulary: 'ICD10CM', + includeInvalid: false, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/mappings'); + expect(url).toContain('target_vocabulary=ICD10CM'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'no mappings')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { error } = await client.mappings.get(9_999_999); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.mappings.map', () => { + test('POST /concepts/map with sourceConcepts variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [], summary: { total_source_concepts: 2 } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826, 1112807], + mappingType: 'direct', + includeInvalid: false, + }); + const { init, url } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/concepts/map'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + target_vocabulary: 'SNOMED', + source_concepts: [201826, 1112807], + mapping_type: 'direct', + include_invalid: false, + }); + }); + + test('POST /concepts/map with sourceCodes variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, + ], + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.source_codes).toEqual([ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, + ]); + expect(body.source_concepts).toBeUndefined(); + }); + + test('vocabRelease is sent as a QUERY param (not in the JSON body)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826], + vocabRelease: '2025.1', + }); + const { url, init } = lastCall(fetchMock); + expect(url).toContain('vocab_release=2025.1'); + const body = JSON.parse(init.body as string); + expect(body.vocab_release).toBeUndefined(); + expect(body.target_vocabulary).toBe('SNOMED'); + }); + + test('rejects zero-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/exactly one/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects both-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [1], + sourceCodes: [{ vocabulary_id: 'X', concept_code: 'Y' }], + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('forwards idempotencyKey to the Idempotency-Key header', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826], + idempotencyKey: 'idem_map_42', + }); + expect(new Headers(lastCall(fetchMock).init.headers).get('idempotency-key')).toBe( + 'idem_map_42', + ); + }); +}); diff --git a/test/relationships/relationships.test.ts b/test/relationships/relationships.test.ts new file mode 100644 index 0000000..6b2aea0 --- /dev/null +++ b/test/relationships/relationships.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID } from '../fixtures/index.js'; +import { createMockFetch, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.relationships.get', () => { + test('hits GET /concepts/{id}/relationships', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationships: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.get(DIABETES_CONCEPT_ID, { + relationshipIds: ['Maps to', 'Is a'], + standardOnly: true, + includeReverse: false, + page: 1, + pageSize: 100, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/relationships'); + expect(url).toContain('relationship_ids=Maps+to%2CIs+a'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_reverse=false'); + expect(url).toContain('page=1'); + expect(url).toContain('page_size=100'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('produces identical URL to concepts.relationships() for shared params', async () => { + const a = createMockFetch(); + const b = createMockFetch(); + enqueueSuccess(a, { relationships: [] }); + enqueueSuccess(b, { relationships: [] }); + const sharedOpts = { + relationshipIds: ['Maps to'], + vocabularyIds: ['SNOMED'], + includeReverse: true, + }; + + const clientA = new OMOPHub('oh_test', { fetch: a }); + const clientB = new OMOPHub('oh_test', { fetch: b }); + await clientA.relationships.get(DIABETES_CONCEPT_ID, sharedOpts); + await clientB.concepts.relationships(DIABETES_CONCEPT_ID, sharedOpts); + + expect(lastCall(a).url).toBe(lastCall(b).url); + }); +}); + +describe('client.relationships.types', () => { + test('hits GET /relationships/types with pagination', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationship_types: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.types({ page: 2, pageSize: 50 }); + const { url } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/relationships/types?page=2&page_size=50'); + }); + + test('uses no query params by default', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationship_types: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.types(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/relationships/types'); + }); +}); diff --git a/test/search/search.test.ts b/test/search/search.test.ts new file mode 100644 index 0000000..e7b9bcd --- /dev/null +++ b/test/search/search.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { OMOPHubIteratorError } from '../../src/errors.js'; +import { + DIABETES_CONCEPT_ID, + mockApiErrorBody, + mockConcept, + mockPagination, +} from '../fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueRawBody, + enqueueSuccess, + lastCall, +} from '../helpers/mock-fetch.js'; + +describe('client.search.basic', () => { + test('hits GET /search/concepts with snake-cased filters', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + domainIds: ['Condition'], + standardConcept: 'S', + pageSize: 50, + sortBy: 'relevance', + }); + + expect(error).toBeNull(); + expect(data?.concepts).toEqual([mockConcept()]); + const { url } = lastCall(fetchMock); + expect(url).toContain('/search/concepts'); + expect(url).toContain('query=diabetes'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('domain_ids=Condition'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('page_size=50'); + expect(url).toContain('sort_by=relevance'); + }); + + test('normalises legacy { data: [...] } shape into { concepts }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [mockConcept()] }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.basic('diabetes'); + expect(data?.concepts).toEqual([mockConcept()]); + }); + + test('normalises bare-array response into { concepts }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockConcept()]); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.basic('diabetes'); + expect(data?.concepts).toEqual([mockConcept()]); + }); + + test('positional `query` wins over options.query.query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.basic('aspirin', { + query: { query: 'override-attempt', trace: 'on' }, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('query=aspirin'); + expect(url).not.toContain('query=override-attempt'); + expect(url).toContain('trace=on'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'no results')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.search.basic('zzz-not-found'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.search.basicIter', () => { + test('yields concepts across multiple pages', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { + concepts: [mockConcept({ concept_id: 1 }), mockConcept({ concept_id: 2 })], + }, + meta: { pagination: mockPagination({ page: 1, page_size: 2, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { + concepts: [mockConcept({ concept_id: 3 })], + }, + meta: { pagination: mockPagination({ page: 2, page_size: 2, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const out: number[] = []; + for await (const c of client.search.basicIter('diabetes', { pageSize: 2 })) { + out.push(c.concept_id); + } + expect(out).toEqual([1, 2, 3]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('throws OMOPHubIteratorError when a page fails', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept()] }, + meta: { pagination: mockPagination({ has_next: true }) }, + }); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const out: number[] = []; + let caught: unknown; + try { + for await (const c of client.search.basicIter('diabetes', { pageSize: 1 })) { + out.push(c.concept_id); + } + } catch (e) { + caught = e; + } + expect(out).toEqual([DIABETES_CONCEPT_ID]); + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + expect((caught as OMOPHubIteratorError).code).toBe('service_unavailable'); + }); +}); + +describe('client.search.basicAll', () => { + test('collects across pages into a flat data array', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 1 })] }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 2 })] }, + meta: { pagination: mockPagination({ page: 2, page_size: 1, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 1, + }); + expect(data.map((c) => c.concept_id)).toEqual([1, 2]); + expect(errors).toEqual([]); + expect(pagesFetched).toBe(2); + }); + + test('respects maxPages', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 1 })] }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 1, + maxPages: 1, + }); + expect(pagesFetched).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('client.search.advanced', () => { + test('hits POST /search/advanced with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.advanced('diabetes type 2', { + vocabularyIds: ['SNOMED'], + standardConceptsOnly: true, + relationshipFilters: [{ relationship_id: 'Is a' }], + }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + query: 'diabetes type 2', + vocabulary_ids: ['SNOMED'], + standard_concepts_only: true, + relationship_filters: [{ relationship_id: 'Is a' }], + }); + }); +}); + +describe('client.search.autocomplete', () => { + test('hits GET /search/suggest with positional query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [{ suggestion: 'diabetes', concept_id: 201826 }]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.autocomplete('diab', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(data).toEqual([{ suggestion: 'diabetes', concept_id: 201826 }]); + const { url } = lastCall(fetchMock); + expect(url).toContain('/search/suggest'); + expect(url).toContain('query=diab'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('page_size=5'); + }); +}); + +describe('client.search.semantic', () => { + test('hits GET /concepts/semantic-search with snake-cased query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { results: [], search_metadata: {} }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.semantic('high blood sugar', { + vocabularyIds: ['SNOMED'], + standardConcept: 'S', + threshold: 0.85, + pageSize: 20, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/semantic-search'); + expect(url).toContain('query=high+blood+sugar'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('threshold=0.85'); + }); +}); + +describe('client.search.semanticIter', () => { + test('yields semantic results across pages, unwrapping the { results: [...] } shape', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { + results: [ + { + concept_id: 1, + concept_name: 'A', + vocabulary_id: 'X', + concept_code: 'a', + similarity_score: 0.9, + }, + ], + }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { + results: [ + { + concept_id: 2, + concept_name: 'B', + vocabulary_id: 'X', + concept_code: 'b', + similarity_score: 0.8, + }, + ], + }, + meta: { pagination: mockPagination({ page: 2, page_size: 1, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const out: number[] = []; + for await (const r of client.search.semanticIter('blood sugar', { pageSize: 1 })) { + out.push(r.concept_id); + } + expect(out).toEqual([1, 2]); + }); +}); + +describe('client.search.bulkBasic', () => { + test('hits POST /search/bulk with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + results: [], + total_searches: 2, + completed_searches: 2, + failed_searches: 0, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes' }, + { search_id: 'q2', query: 'hypertension' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, + ); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.searches).toHaveLength(2); + expect(body.searches[0]).toEqual({ search_id: 'q1', query: 'diabetes' }); + expect(body.defaults).toEqual({ vocabulary_ids: ['SNOMED'], page_size: 5 }); + }); + + test('rejects >50 searches synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 51 }, (_, i) => ({ + search_id: `q${i}`, + query: `${i}`, + })); + const { error } = await client.search.bulkBasic(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty searches synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.search.bulkBasic([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.search.bulkSemantic', () => { + test('hits POST /search/semantic-bulk with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + results: [], + total_searches: 1, + completed_count: 1, + failed_count: 0, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.bulkSemantic([{ search_id: 'sq1', query: 'blood sugar', threshold: 0.8 }]); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.searches[0].threshold).toBe(0.8); + }); + + test('rejects >25 searches synthetically (lower cap than bulkBasic)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 26 }, (_, i) => ({ + search_id: `q${i}`, + query: `${i}`, + })); + const { error } = await client.search.bulkSemantic(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.search.similar', () => { + test('hits POST /search/similar with conceptId variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: '201826', + algorithm_used: 'hybrid', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ concept_id: 201826, algorithm: 'hybrid' }); + }); + + test('accepts conceptName variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: 'diabetes', + algorithm_used: 'semantic', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ conceptName: 'diabetes', algorithm: 'semantic' }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.concept_name).toBe('diabetes'); + }); + + test('accepts query variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: 'high blood sugar', + algorithm_used: 'hybrid', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ query: 'high blood sugar' }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.query).toBe('high blood sugar'); + }); + + test('rejects zero-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Cast through unknown — TS would normally reject this at compile time. + const { error } = await client.search.similar( + {} as unknown as Parameters[0], + ); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/exactly one/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects multiple-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.search.similar({ + conceptId: 1, + conceptName: 'x', + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 5752c7316e6100315596d56c752773b329a3ec57 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 30 May 2026 20:37:06 +0100 Subject: [PATCH 05/16] Refactor mappings and search interfaces for improved validation and response handling. Update `ConceptRelationshipsOptions` to include pagination options, enhance error messages for empty input arrays, and normalize search response structures. Add tests to ensure proper error handling for invalid inputs. --- .../concept-relationships-options.ts | 9 ++- src/mappings/interfaces/mapping.ts | 20 ++++-- src/mappings/mappings.ts | 6 +- .../interfaces/advanced-search-options.ts | 8 ++- src/search/search.ts | 43 +++++++++-- test/mappings/mappings.test.ts | 23 ++++++ test/search/search.test.ts | 72 ++++++++++++++++++- 7 files changed, 160 insertions(+), 21 deletions(-) diff --git a/src/concepts/interfaces/concept-relationships-options.ts b/src/concepts/interfaces/concept-relationships-options.ts index 6757d47..d8dc445 100644 --- a/src/concepts/interfaces/concept-relationships-options.ts +++ b/src/concepts/interfaces/concept-relationships-options.ts @@ -1,13 +1,18 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; /** * Options for `concepts.relationships(conceptId, ...)`. * - * Note: shares the wire endpoint `GET /concepts/{id}/relationships` with + * Shares the wire endpoint `GET /concepts/{id}/relationships` with * `relationships.get(conceptId, ...)`. Kept as a separate method so users * who think in concept-centric terms have a discoverable entry point. + * + * Field list must stay aligned with `GetRelationshipsOptions` (in + * `relationships/interfaces/`) since both methods hit the same endpoint — + * a parity test in `relationships.test.ts` enforces equivalent wire URLs. */ -export interface ConceptRelationshipsOptions extends VocabReleaseMixin { +export interface ConceptRelationshipsOptions extends PaginationOptions, VocabReleaseMixin { relationshipIds?: string[]; vocabularyIds?: string[]; domainIds?: string[]; diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts index 59eef33..882b8c7 100644 --- a/src/mappings/interfaces/mapping.ts +++ b/src/mappings/interfaces/mapping.ts @@ -43,11 +43,21 @@ export interface MappingsListResult { summary?: MappingsSummary; } -export interface FailedMapping { - source_concept_id?: number; - source_code?: { vocabulary_id: string; concept_code: string }; - reason?: string; -} +/** + * Failed-mapping entry in `MapConceptsResult.failed_mappings`. Discriminated + * by which input variant the failure traces back to — every failed mapping + * carries either a `source_concept_id` (from the `sourceConcepts` input) or + * a `source_code` (from the `sourceCodes` input), never an empty object. + * `reason` is optional, mirroring `BatchConceptResult.failed_concepts` in + * `concepts/interfaces/`. + */ +export type FailedMapping = + | { source_concept_id: number; source_code?: never; reason?: string } + | { + source_concept_id?: never; + source_code: { vocabulary_id: string; concept_code: string }; + reason?: string; + }; export interface MapConceptsResult { mappings: Mapping[]; diff --git a/src/mappings/mappings.ts b/src/mappings/mappings.ts index 1b26564..6be6b6b 100644 --- a/src/mappings/mappings.ts +++ b/src/mappings/mappings.ts @@ -47,12 +47,12 @@ export class Mappings { async map( options: MapConceptsOptions & PostOptions, ): Promise> { - const hasConcepts = Array.isArray(options.sourceConcepts); - const hasCodes = Array.isArray(options.sourceCodes); + const hasConcepts = Array.isArray(options.sourceConcepts) && options.sourceConcepts.length > 0; + const hasCodes = Array.isArray(options.sourceCodes) && options.sourceCodes.length > 0; if (hasConcepts === hasCodes) { return syntheticError( 'missing_required_field', - 'Provide exactly one of `sourceConcepts` or `sourceCodes`.', + 'Provide exactly one of `sourceConcepts` or `sourceCodes` with at least one entry.', ); } diff --git a/src/search/interfaces/advanced-search-options.ts b/src/search/interfaces/advanced-search-options.ts index dd75709..10a1565 100644 --- a/src/search/interfaces/advanced-search-options.ts +++ b/src/search/interfaces/advanced-search-options.ts @@ -1,8 +1,12 @@ import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +/** + * camelCase input shape — `toSnakeCaseKeys` recurses into the array and + * converts to `relationship_id` / `target_concept_id` at the wire. + */ export interface RelationshipFilter { - relationship_id: string; - target_concept_id?: number; + relationshipId: string; + targetConceptId?: number; direction?: 'forward' | 'reverse'; } diff --git a/src/search/search.ts b/src/search/search.ts index 61ee75d..f63b749 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -168,17 +168,41 @@ export class Search { /** * Semantic (embedding-based) search. + * + * Normalises the response so `data.results` is always a populated array, + * regardless of whether the server returned `{ results: [...] }`, + * `{ data: [...] }`, or a bare array — same defensive shape-handling + * the iter / all variants apply. */ async semantic( query: string, options: SemanticSearchOptions & GetOptions = {}, ): Promise> { const { signal, headers, query: extraQuery, ...flags } = options; - return this.client.get('/concepts/semantic-search', { + const response = await this.client.get('/concepts/semantic-search', { signal, headers, query: { ...flags, ...extraQuery, query }, }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + const results = normaliseSemanticSearchData(response.data); + const raw = + response.data && typeof response.data === 'object' && !Array.isArray(response.data) + ? (response.data as Record) + : {}; + const normalised: SemanticSearchResultSet = { results }; + if (raw.search_metadata && typeof raw.search_metadata === 'object') { + normalised.search_metadata = + raw.search_metadata as SemanticSearchResultSet['search_metadata']; + } + return { + data: normalised, + error: null, + meta: response.meta, + headers: response.headers, + }; } /** @@ -295,15 +319,20 @@ export class Search { options: SimilarSearchOptions, requestOptions: PostOptions = {}, ): Promise> { - const provided = [ - options.conceptId !== undefined, - options.conceptName !== undefined, - options.query !== undefined, - ].filter(Boolean).length; + // Tight checks rather than `!== undefined`: a JS caller passing + // `null`, `''`, or `NaN` for one of the XOR fields should be treated + // as "not provided" and rejected synthetically, not forwarded to the + // API as a malformed POST. + const hasConceptId = + typeof options.conceptId === 'number' && Number.isFinite(options.conceptId); + const hasConceptName = + typeof options.conceptName === 'string' && options.conceptName.length > 0; + const hasQuery = typeof options.query === 'string' && options.query.length > 0; + const provided = [hasConceptId, hasConceptName, hasQuery].filter(Boolean).length; if (provided !== 1) { return syntheticError( 'missing_required_field', - 'Provide exactly one of `conceptId`, `conceptName`, or `query`.', + 'Provide exactly one of `conceptId`, `conceptName`, or `query` (non-empty).', ); } const body = toSnakeCaseKeys(options); diff --git a/test/mappings/mappings.test.ts b/test/mappings/mappings.test.ts index 7df57ec..52fc745 100644 --- a/test/mappings/mappings.test.ts +++ b/test/mappings/mappings.test.ts @@ -110,6 +110,29 @@ describe('client.mappings.map', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + test('rejects empty sourceConcepts array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/at least one entry/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty sourceCodes array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test('forwards idempotencyKey to the Idempotency-Key header', async () => { const fetchMock = createMockFetch(); enqueueSuccess(fetchMock, { mappings: [] }); diff --git a/test/search/search.test.ts b/test/search/search.test.ts index e7b9bcd..84976f2 100644 --- a/test/search/search.test.ts +++ b/test/search/search.test.ts @@ -183,7 +183,7 @@ describe('client.search.advanced', () => { await client.search.advanced('diabetes type 2', { vocabularyIds: ['SNOMED'], standardConceptsOnly: true, - relationshipFilters: [{ relationship_id: 'Is a' }], + relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: 201826 }], }); const { init } = lastCall(fetchMock); expect(init.method).toBe('POST'); @@ -192,7 +192,7 @@ describe('client.search.advanced', () => { query: 'diabetes type 2', vocabulary_ids: ['SNOMED'], standard_concepts_only: true, - relationship_filters: [{ relationship_id: 'Is a' }], + relationship_filters: [{ relationship_id: 'Is a', target_concept_id: 201826 }], }); }); }); @@ -234,6 +234,43 @@ describe('client.search.semantic', () => { expect(url).toContain('standard_concept=S'); expect(url).toContain('threshold=0.85'); }); + + test('normalises a bare-array response into { results: [...] }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [ + { + concept_id: 1, + concept_name: 'A', + vocabulary_id: 'X', + concept_code: 'a', + similarity_score: 0.9, + }, + ]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.semantic('q'); + expect(error).toBeNull(); + expect(data?.results).toHaveLength(1); + expect(data?.results[0]?.concept_id).toBe(1); + }); + + test('normalises legacy { data: [...] } shape into { results: [...] }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + data: [ + { + concept_id: 2, + concept_name: 'B', + vocabulary_id: 'X', + concept_code: 'b', + similarity_score: 0.7, + }, + ], + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.semantic('q'); + expect(data?.results).toHaveLength(1); + expect(data?.results[0]?.concept_id).toBe(2); + }); }); describe('client.search.semanticIter', () => { @@ -437,4 +474,35 @@ describe('client.search.similar', () => { expect(error?.name).toBe('missing_required_field'); expect(fetchMock).not.toHaveBeenCalled(); }); + + test('treats null/empty/NaN XOR values as not-provided (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + + expect( + ( + await client.search.similar({ + conceptId: null, + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect( + ( + await client.search.similar({ + query: '', + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect( + ( + await client.search.similar({ + conceptId: Number.NaN, + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); }); From 82b3dd4e938da87d8f45530c62c34065f552640f Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 07:55:09 +0100 Subject: [PATCH 06/16] Update biome configuration to include e2e tests in linting and formatting processes. Enhance README with usage examples and configuration details. Add comprehensive e2e test suite covering various API interactions, including error handling and response validation. Introduce new TypeScript configuration for e2e tests and implement minimal environment variable loading for test execution. --- CHANGELOG.md | 78 +++++ README.md | 264 ++++++++++++++++- biome.json | 4 +- e2e/_helpers.ts | 67 +++++ e2e/abort-timeout.test.ts | 79 +++++ e2e/auth.test.ts | 47 +++ e2e/concepts.test.ts | 269 +++++++++++++++++ e2e/domains.test.ts | 100 +++++++ e2e/fhir.test.ts | 191 ++++++++++++ e2e/hierarchy-relationships-mappings.test.ts | 247 ++++++++++++++++ e2e/pagination.test.ts | 124 ++++++++ e2e/search.test.ts | 276 ++++++++++++++++++ e2e/server-errors.test.ts | 103 +++++++ e2e/url-encoding.test.ts | 103 +++++++ e2e/vocabularies.test.ts | 153 ++++++++++ package.json | 11 +- src/auth/auth.ts | 39 +++ src/client.ts | 3 + src/concepts/interfaces/concept.ts | 96 ++++-- src/concepts/interfaces/index.ts | 3 +- src/domains/domains.ts | 19 +- src/domains/interfaces/domain.ts | 16 + src/domains/interfaces/index.ts | 8 +- src/fhir/fhir-url.ts | 18 ++ src/fhir/fhir.ts | 130 +++++++++ src/fhir/interfaces/fhir.ts | 80 +++++ src/fhir/interfaces/index.ts | 14 + src/fhir/interfaces/resolve-batch-options.ts | 8 + .../resolve-codeable-concept-options.ts | 9 + src/fhir/interfaces/resolve-options.ts | 34 +++ src/index.ts | 19 +- src/search/interfaces/bulk-search.ts | 20 +- src/search/interfaces/index.ts | 2 + src/search/interfaces/search-result.ts | 19 ++ src/search/search.ts | 12 +- src/vocabularies/interfaces/index.ts | 4 + src/vocabularies/interfaces/vocabulary.ts | 29 ++ src/vocabularies/vocabularies.ts | 39 +-- test/auth/auth.test.ts | 49 ++++ test/fhir/fhir-url.test.ts | 14 + test/fhir/fhir.test.ts | 253 ++++++++++++++++ tsconfig.e2e.json | 9 + vitest.e2e.config.ts | 34 +++ 43 files changed, 3019 insertions(+), 77 deletions(-) create mode 100644 e2e/_helpers.ts create mode 100644 e2e/abort-timeout.test.ts create mode 100644 e2e/auth.test.ts create mode 100644 e2e/concepts.test.ts create mode 100644 e2e/domains.test.ts create mode 100644 e2e/fhir.test.ts create mode 100644 e2e/hierarchy-relationships-mappings.test.ts create mode 100644 e2e/pagination.test.ts create mode 100644 e2e/search.test.ts create mode 100644 e2e/server-errors.test.ts create mode 100644 e2e/url-encoding.test.ts create mode 100644 e2e/vocabularies.test.ts create mode 100644 src/auth/auth.ts create mode 100644 src/fhir/fhir-url.ts create mode 100644 src/fhir/fhir.ts create mode 100644 src/fhir/interfaces/fhir.ts create mode 100644 src/fhir/interfaces/index.ts create mode 100644 src/fhir/interfaces/resolve-batch-options.ts create mode 100644 src/fhir/interfaces/resolve-codeable-concept-options.ts create mode 100644 src/fhir/interfaces/resolve-options.ts create mode 100644 test/auth/auth.test.ts create mode 100644 test/fhir/fhir-url.test.ts create mode 100644 test/fhir/fhir.test.ts create mode 100644 tsconfig.e2e.json create mode 100644 vitest.e2e.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ddecf15..f7a323c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,84 @@ All notable changes to this project will be documented in this file. The format - `client.mappings` — 2 methods: `get` and `map`. `map` enforces XOR of `sourceConcepts` vs `sourceCodes` at both type and runtime levels. `vocabRelease` is routed to the `?vocab_release=` query parameter rather than the JSON body (matches Python SDK convention). JSDoc documents the Procedure-domain vocabulary priority chain (SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → ICD9Proc → OPCS4 → OMOP Extension). - `ConceptHierarchyNode` extended with `domain_id`, `concept_class_id`, `standard_concept` optional fields — now matches Python's `HierarchyConcept` and is re-exported as `HierarchyConcept`/`Ancestor`/`Descendant` from the hierarchy module. - `ConceptRelationship` re-exported as `Relationship` from the relationships module — kept in sync via type alias. +- `client.fhir` — 3 methods: `resolve` (accepts both flat `{ system, code }` and nested `{ coding: {...} }` forms, mirroring the Python SDK's `_extract_coding`), `resolveBatch` (1–100 codings), `resolveCodeableConcept` (1–20 codings). All three validate synthetically before issuing requests. +- Standalone helpers (no client required): + - `omophubFhirUrl(version)` — returns the URL of OMOPHub's hosted FHIR Terminology Service (`'r4'` default, also `'r4b'`, `'r5'`, `'r6'`). + - `getApiKey()`, `setApiKey(key)`, `hasApiKey()` — env-backed helpers reading `OMOPHUB_API_KEY` from `process.env`. `setApiKey` throws `OMOPHubError` on edge runtimes that lack a writable `process.env`. +- FHIR `Coding` type uses camelCase (`userSelected`, `vocabularyId`) to match the FHIR JSON spec — converted to snake_case at the wire via `toSnakeCaseKeys`. +- README polish: install + config table + per-resource usage examples for all 8 resources + error-handling guide + async-iterator guide + Python/R migration table. + +### SDK surface at v0.1.0 — 37 methods across 8 resources + +``` +client.concepts — get, getByCode, batch, suggest, related, relationships, recommended (7) +client.search — basic, basicIter, basicAll, advanced, autocomplete, semantic, + semanticIter, semanticAll, bulkBasic, bulkSemantic, similar (11) +client.vocabularies — list, get, stats, domainStats, domains, conceptClasses, concepts (7) +client.domains — list, concepts (2) +client.hierarchy — get, ancestors, descendants (3) +client.relationships — get, types (2) +client.mappings — get, map (2) +client.fhir — resolve, resolveBatch, resolveCodeableConcept (3) + Σ = 37 + +Standalone: omophubFhirUrl, getApiKey, setApiKey, hasApiKey +``` + +### Fixed (post-PR-6 e2e validation against live API — first pass) + +The initial 23-test e2e smoke suite uncovered response-shape drift between speculative types and the actual live API. Two of these (`vocabularies.concepts`, `vocabularies.conceptClasses`) were superseded by the comprehensive integration sweep below: + +- `vocabularies.list` → `ListVocabulariesResult = { vocabularies: Vocabulary[] }` (was union of bare array / generic `PaginatedData`). +- `vocabularies.concepts` → first typed as `{ concepts: [...] }`; **superseded** to bare `Concept[]` after the wider sweep. +- `vocabularies.domains` → `ListVocabularyDomainsResult = { domains: VocabularyDomain[] }`. +- `vocabularies.conceptClasses` → first typed as `{ concept_classes: [...] }`; **superseded** to bare `ConceptClass[]` after the wider sweep. +- `domains.list` → `ListDomainsResult = { domains: Domain[] }`. +- `domains.concepts` → `DomainConceptsResult = { concepts: ConceptSummary[] }`. +- `search.autocomplete` → `AutocompleteResult = { query: string, suggestions: AutocompleteEntry[] }`. Each entry nests the concept under `suggestion` and may carry `match_score` / `match_type`. + +**Pattern (refined after deeper integration testing):** the API uses named-wrapper objects for *most* collection responses but a handful of endpoints return bare arrays. See the "Confirmed wire patterns" section below for the authoritative list. Pagination metadata consistently lives on the outer envelope `Response.meta.pagination`, never nested inside `data`. + +### Added (testing — comprehensive integration sweep) + +- E2E test suite (`e2e/`) — **121 integration tests across 11 files** hitting `api.omophub.com/v1` live, gated by `OMOPHUB_API_KEY` (auto-skip when unset, never fail). Coverage: + - Per-resource happy-path + edge-case tests for all 8 resources. + - **`auth.test.ts`** — bad/empty API keys → live 401 mapping. + - **`server-errors.test.ts`** — 404/400/empty across resources. + - **`url-encoding.test.ts`** — slashes, spaces, ampersand, hash, Cyrillic, Japanese in queries. + - **`pagination.test.ts`** — `basicAll` maxPages cap, iterator early-break, has_next detection, partial-result accumulation. + - **`abort-timeout.test.ts`** — pre-aborted signal re-throws `AbortError`, mid-request abort, very-short `timeoutMs` returns `timeout_error`. + - **`hierarchy-relationships-mappings.test.ts`** — flat-vs-graph format, includeReverse parity, mapping vocabRelease query routing. + - **`fhir.test.ts`** — flat vs. nested coding forms, batch summary counts, includeQuality string, XOR validation. +- `vitest.e2e.config.ts` with sequential execution + 500 ms soft throttle + 90 s per-test timeout for live-API latency. +- Minimal `.env` loader (no `dotenv` runtime dep). +- `npm run test:e2e` script + `tsconfig.e2e.json`. +- `e2eClient()` (full retries + 45 s timeout) and `e2eClientNoRetry()` (no retries + 20 s timeout) helpers for happy-path vs. expected-error test paths. + +### Fixed (post-integration shape drift — 9 more wire-shape corrections) + +The expanded integration sweep uncovered additional response-shape mismatches the initial 23-test smoke suite hadn't exercised: + +- `vocabularies.concepts(vocabId)` → `VocabularyConceptsResult = Concept[]` (bare array, was speculatively typed as `{ concepts: [...] }`). +- `vocabularies.conceptClasses()` → `ListConceptClassesResult = ConceptClass[]` (bare array, was `{ concept_classes: [...] }`). +- `concepts.get(id, { includeRelationships })` → `Concept.relationships?: { parents?: ConceptRelationshipNode[]; children?: ConceptRelationshipNode[] }` — the include-flag form returns a compact `{ parents, children }` shape, **not** a flat `ConceptRelationship[]`. New `ConceptRelationshipNode` type added. +- `Concept` extended with `is_valid?`, `is_standard?`, `is_classification?` boolean convenience fields the server populates. +- `concepts.related(id)` → `RelatedConceptsResult = RelatedConcept[]` (bare array, was `{ related_concepts: [...] }`). `RelatedConcept` fields corrected: `relationship_id` / `relationship_name` / `relationship_score` / `relationship_distance` (was speculative `relatedness_score` / `relatedness_type`). +- `concepts.recommended({ conceptIds: [...] })` → `RecommendedConceptsResult = Record` — keyed by source concept ID **as a string**, not a flat `{ recommendations: [...] }`. Use `Object.entries(data)` to iterate. +- `search.bulkBasic` → `BulkBasicSearchResponse = BulkBasicResultItem[]` (bare array, was `{ results, total_searches, ... }` wrapper). +- `search.bulkSemantic` stays as a wrapper (`{ results, total_searches, completed_count, failed_count, total_duration? }`) — **different from `bulkBasic`**, the semantic endpoint adds aggregate counts. +- `FhirResolution.mapping_quality?: string` (bucket name like `'high'` / `'medium'` / `'low'` / `'manual_review'`, was speculatively typed as `Record`). +- Dropped speculative `ConceptRecommendation` interface (replaced by `RecommendedConceptEntry` keyed by source ID). + +### Confirmed wire patterns + +After the full integration sweep, the API's actual conventions: + +- **List endpoints are inconsistent**: most use named wrappers (`{ vocabularies }`, `{ domains }`, `{ relationship_types }`), but several return bare arrays (`vocabularies.concepts`, `vocabularies.conceptClasses`, `concepts.related`, `search.bulkBasic`). No way to predict from method name alone — the SDK types are now wire-accurate. +- **Single-resource endpoints** return the resource directly (`concepts.get`, `vocabularies.get`, etc.). +- **Pagination metadata** lives on the outer `Response.meta.pagination`, never inside `data`. +- **Server-side error handling varies**: unknown domains return `200 + { concepts: [] }`, unknown vocabularies return a structured 400/404, hierarchy validates `maxLevels` rather than silently capping. +- **Known slow paths**: `getByCode + includeRelationships`, `domains.concepts('Drug', { vocabularyIds: ['RxNorm'] })`, `hierarchy.get('flat')`, `search.basic(..., { exactMatch: true })` can take 25–60 s. The e2e suite tolerates these with the no-retry client + structured-timeout assertions. - `basic` and `advanced` normalise three known server response shapes (`{ concepts, facets, search_metadata }`, legacy `{ data: [...] }`, bare `Concept[]`) into a stable `SearchResult` — addresses the cross-SDK shape drift documented in `project_search_api_response_shapes.md`. - `similar` uses a two-arg `(options, requestOptions)` signature (not the merged style) because its `query` XOR field would otherwise collide with `PerCallOptions.query`. - `paginate()` async generator and `paginateAll()` eager collector in `common/utils/` — generic page-walking helpers used by the search `*Iter` / `*All` variants. The async generator throws `OMOPHubIteratorError` on page failure; `paginateAll` accumulates errors as values. diff --git a/README.md b/README.md index e285685..b096d8d 100644 --- a/README.md +++ b/README.md @@ -17,24 +17,274 @@ import { OMOPHub } from '@omophub/omophub-node'; const client = new OMOPHub(process.env.OMOPHUB_API_KEY); -// Coming in v0.1.0: -// const { data, error } = await client.concepts.get({ conceptId: 201826 }); -// if (error) throw new Error(error.message); -// console.log(data.concept_name); // → "Type 2 diabetes mellitus" +const { data, error } = await client.concepts.get(201826); +if (error) throw new Error(error.message); +console.log(data.concept_name); // → "Type 2 diabetes mellitus" ``` +Every method returns a discriminated `{ data, error, meta, headers }` — errors are values, not exceptions. Narrow with `if (error) ...` and TypeScript will type `data` correctly in the success branch. + ## Configuration | Option | Env var | Default | |---|---|---| | `apiKey` (1st constructor arg) | `OMOPHUB_API_KEY` | — (required) | | `baseUrl` | `OMOPHUB_API_URL` | `https://api.omophub.com/v1` | -| `timeoutMs` | — | `30000` | -| `maxRetries` | — | `3` | -| `vocabVersion` | — | unset (server default) | +| `timeoutMs` | — | `30000` (set to `0` to disable) | +| `maxRetries` | — | `3` (set to `0` to disable) | +| `vocabVersion` | — | unset (server picks latest) | | `userAgent` | — | `omophub-node/` | | `fetch` | — | `globalThis.fetch` | +```ts +const client = new OMOPHub('oh_...', { + baseUrl: 'https://staging.api.omophub.com/v1', + timeoutMs: 60_000, + maxRetries: 5, + vocabVersion: '2025.1', +}); +``` + +## Resources + +The client exposes 8 resources covering the full OMOPHub API surface. + +### Concepts + +```ts +// Single concept by OMOP ID +await client.concepts.get(201826, { includeRelationships: true }); + +// By native vocabulary code +await client.concepts.getByCode('SNOMED', '44054006'); + +// Up to 100 concepts in one call +await client.concepts.batch({ conceptIds: [201826, 1112807] }); + +// Type-ahead concept suggestions +await client.concepts.suggest('diab', { pageSize: 5 }); + +// Phoebe-style related concepts +await client.concepts.related(201826, { minScore: 0.5 }); + +// Relationship list for a concept +await client.concepts.relationships(201826); + +// OHDSI Phoebe recommendations +await client.concepts.recommended({ conceptIds: [201826], standardOnly: true }); +``` + +### Search + +```ts +// Keyword search — normalises both modern and legacy response shapes +const { data } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + standardConcept: 'S', + pageSize: 50, +}); +console.log(data.concepts.length); + +// Async iterator — walk every page +for await (const c of client.search.basicIter('diabetes', { pageSize: 100 })) { + console.log(c.concept_id); +} + +// Eager collect across pages +const { data: allConcepts, errors } = await client.search.basicAll('diabetes', { + maxPages: 5, +}); + +// Advanced (POST) — relationship filters +await client.search.advanced('diabetes', { + relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: 4034964 }], +}); + +// Semantic (embedding-based) search +await client.search.semantic('high blood sugar', { threshold: 0.85 }); + +// Bulk: 50 basic / 25 semantic searches per call +await client.search.bulkBasic([ + { search_id: 'q1', query: 'diabetes' }, + { search_id: 'q2', query: 'hypertension' }, +]); + +// Similarity search (exactly one of conceptId / conceptName / query) +await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); +``` + +### Vocabularies + +```ts +await client.vocabularies.list({ includeStats: true }); +await client.vocabularies.get('SNOMED'); +await client.vocabularies.stats('SNOMED'); +await client.vocabularies.domainStats('SNOMED', 'Condition'); +await client.vocabularies.domains(); // vocab-scoped +await client.vocabularies.conceptClasses(); +await client.vocabularies.concepts('SNOMED', { search: 'diabetes', pageSize: 100 }); +``` + +### Domains + +```ts +await client.domains.list(); // ~20-row catalog +await client.domains.concepts('Condition', { pageSize: 100 }); +``` + +### Hierarchy + +```ts +await client.hierarchy.get(201826, { format: 'graph', maxLevels: 5 }); +await client.hierarchy.ancestors(201826, { vocabularyIds: ['SNOMED'] }); +await client.hierarchy.descendants(201826, { maxLevels: 3, domainIds: ['Condition'] }); +``` + +### Relationships + +```ts +await client.relationships.get(201826, { standardOnly: true }); +await client.relationships.types(); +``` + +### Mappings + +```ts +await client.mappings.get(201826, { targetVocabulary: 'ICD10CM' }); + +// Map by OMOP concept IDs +await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826, 1112807], +}); + +// Map by native vocabulary codes +await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, + ], +}); +``` + +### FHIR + +```ts +// Resolve a single FHIR coding to its OMOP standard concept +const { data } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', +}); +console.log(data.resolution.standard_concept.concept_name); +console.log(data.resolution.target_table); // → "condition_occurrence" + +// Up to 100 codings at once +await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition', includeQuality: true }, +); + +// CodeableConcept resolution (up to 20 codings, picks best match) +await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006', userSelected: true }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { text: 'Type 2 diabetes mellitus' }, +); +``` + +## Error handling + +Every method returns a discriminated union — never throws on network or API errors. + +```ts +const { data, error, headers } = await client.concepts.get(999_999_999); +if (error) { + console.error(error.name, error.message, error.statusCode); + if (error.name === 'rate_limit_exceeded') { + console.log('Retry after', error.retryAfter, 'seconds'); + } + return; +} +console.log(data.concept_name); // narrowed +``` + +Error codes (`error.name`): + +| Code | Cause | +|---|---| +| `invalid_api_key`, `restricted_api_key`, `missing_api_key` | 401 / 403 | +| `not_found` | 404 | +| `validation_error`, `missing_required_field`, `invalid_argument` | 400 | +| `method_not_allowed`, `conflict` | 405 / 409 | +| `rate_limit_exceeded`, `tier_limit_exceeded` | 429 | +| `service_unavailable`, `internal_server_error` | 5xx | +| `connection_error`, `timeout_error` | Transport | +| `application_error` | Catch-all | + +The client retries `429`, `502`, `503`, `504`, and transient network errors automatically (jittered exponential backoff, `Retry-After` honoured up to 60s). **POST and PATCH only retry when an `Idempotency-Key` header is set** — pass it via `{ idempotencyKey: '...' }`. + +## Async iterators + +`*Iter` variants are async generators — they throw `OMOPHubIteratorError` if a page fails, since generators can't yield discriminated errors gracefully. `*All` variants accumulate errors into the return value: + +```ts +import { OMOPHubIteratorError } from '@omophub/omophub-node'; + +try { + for await (const c of client.search.basicIter('diabetes')) { + console.log(c.concept_id); + } +} catch (e) { + if (e instanceof OMOPHubIteratorError) { + console.error(e.code, e.statusCode); + } +} + +// Or the value-based variant: +const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + maxPages: 10, +}); +``` + +## Standalone helpers + +```ts +import { + omophubFhirUrl, + getApiKey, + setApiKey, + hasApiKey, +} from '@omophub/omophub-node'; + +// Pointer to OMOPHub's hosted FHIR Terminology Service +omophubFhirUrl(); // → "https://fhir.omophub.com/fhir/r4" +omophubFhirUrl('r5'); // → "https://fhir.omophub.com/fhir/r5" + +// Env-backed key helpers +getApiKey(); // process.env.OMOPHUB_API_KEY +setApiKey('oh_...'); // writes the env var +hasApiKey(); // boolean +``` + +## Migration from the Python or R SDK + +| Python | Node | Notes | +|---|---|---| +| `client.concepts.get(201826)` | `client.concepts.get(201826)` | Same | +| `client.concepts.batch(concept_ids=[1,2])` | `client.concepts.batch({ conceptIds: [1,2] })` | Options object | +| `client.search.basic(query, vocabulary_ids=[...])` | `client.search.basic(query, { vocabularyIds: [...] })` | camelCase options | +| `client.mappings.map('SNOMED', source_concepts=[1])` | `client.mappings.map({ targetVocabulary: 'SNOMED', sourceConcepts: [1] })` | All in options | +| `client.fhir.resolve(system='...', code='...')` | `client.fhir.resolve({ system: '...', code: '...' })` | Single options object | + +Wire-format snake_case is preserved on **response** types (`data.concept_id`, `data.vocabulary_id`) — same as Python and R. Option keys are camelCase in TS and converted to snake_case at the request boundary. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/biome.json b/biome.json index 7c48783..1ac106e 100644 --- a/biome.json +++ b/biome.json @@ -34,11 +34,11 @@ } }, "files": { - "includes": ["src/**", "test/**"] + "includes": ["src/**", "test/**", "e2e/**"] }, "overrides": [ { - "includes": ["test/**"], + "includes": ["test/**", "e2e/**"], "linter": { "rules": { "style": { diff --git a/e2e/_helpers.ts b/e2e/_helpers.ts new file mode 100644 index 0000000..dbae52e --- /dev/null +++ b/e2e/_helpers.ts @@ -0,0 +1,67 @@ +import { OMOPHub } from '../src/index.js'; + +/** + * Well-known concept IDs used across the e2e smoke suite. Same set the + * Python and R SDK test suites use for cross-SDK parity. + */ +export const E2E_CONCEPT_IDS = { + diabetes: 201826, + aspirin: 1112807, + hypertension: 316866, + covid: 37311061, +} as const; + +const API_KEY = process.env.OMOPHUB_API_KEY; + +/** + * Skip-or-run guard. If `OMOPHUB_API_KEY` isn't present in the + * environment (CI without the secret, contributor without `.env`), the + * suite no-ops with a clear message instead of failing. + */ +export const e2eEnabled = typeof API_KEY === 'string' && API_KEY.length > 0; + +/** + * Throws if you try to construct a client without the key — `e2eEnabled` + * gates this so it should never fire inside an e2e test. + */ +export function e2eClient(): OMOPHub { + if (!e2eEnabled) { + throw new Error('e2eClient() called without OMOPHUB_API_KEY — guard with e2eEnabled.'); + } + return new OMOPHub(API_KEY, { + // Generous timeout: some live queries (semantic search, exactMatch) + // can take 15–25 s before responding. + timeoutMs: 45_000, + maxRetries: 2, + }); +} + +/** + * Client variant for tests that EXPECT an error response (404, 400, etc.) + * — disables retries so a known-bad call fails fast and stays inside the + * 60 s test timeout. Used by `server-errors.test.ts` and similar. + */ +export function e2eClientNoRetry(): OMOPHub { + if (!e2eEnabled) { + throw new Error('e2eClientNoRetry() called without OMOPHUB_API_KEY.'); + } + return new OMOPHub(API_KEY, { + timeoutMs: 20_000, + maxRetries: 0, + }); +} + +/** + * Brief per-suite throttle to avoid hot-spotting the API rate limiter + * when the full e2e suite runs back-to-back. ~2 req/sec global ceiling. + * + * The default 200ms was too aggressive — running ~120 sequential tests + * triggered server-side rate limits even though each test made only one + * call. 500ms keeps the suite under any reasonable per-minute quota. + */ +let lastRequestAt = 0; +export async function softThrottle(): Promise { + const elapsed = Date.now() - lastRequestAt; + if (elapsed < 500) await new Promise((r) => setTimeout(r, 500 - elapsed)); + lastRequestAt = Date.now(); +} diff --git a/e2e/abort-timeout.test.ts b/e2e/abort-timeout.test.ts new file mode 100644 index 0000000..5b8b673 --- /dev/null +++ b/e2e/abort-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../src/index.js'; +import { e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +const API_KEY = process.env.OMOPHUB_API_KEY ?? ''; + +describe('e2e: AbortSignal + timeout', () => { + runOrSkip('pre-aborted signal re-throws AbortError without hitting the network', async () => { + const controller = new AbortController(); + controller.abort(new DOMException('caller-aborted', 'AbortError')); + + const client = new OMOPHub(API_KEY, { maxRetries: 0 }); + let caught: unknown; + try { + await client.vocabularies.list({ signal: controller.signal }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).name).toBe('AbortError'); + }); + + runOrSkip('signal aborted mid-flight re-throws AbortError', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('mid-flight', 'AbortError')), 5); + + const client = new OMOPHub(API_KEY, { maxRetries: 0 }); + let caught: unknown; + try { + await client.search.basic('diabetes', { + pageSize: 100, + signal: controller.signal, + }); + } catch (e) { + caught = e; + } + // Either the request was aborted (AbortError thrown) or it completed + // before the abort fired — both are valid race outcomes + if (caught) { + expect((caught as Error).name).toBe('AbortError'); + } + }); + + runOrSkip('very short timeoutMs returns timeout_error, does not throw', async () => { + await softThrottle(); + const client = new OMOPHub(API_KEY, { + maxRetries: 0, + timeoutMs: 1, // 1ms — the live API can't respond that fast + }); + const { data, error } = await client.search.basic('diabetes', { pageSize: 100 }); + expect(data).toBeNull(); + // Either timeout fired before the request resolved, or it completed + // anyway (network/process latency variance). If it errored, it must be + // a timeout_error or connection_error. + if (error) { + expect(['timeout_error', 'connection_error']).toContain(error.name); + } + }); + + runOrSkip('caller signal aborts during retry sleep also re-throws AbortError', async () => { + // Trigger by hitting a 503-ish endpoint that retries, but abort during + // the backoff. We approximate this with a short timeoutMs + max retries. + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('during-retry', 'AbortError')), 20); + + const client = new OMOPHub(API_KEY, { maxRetries: 3 }); + let caught: unknown; + try { + await client.vocabularies.list({ signal: controller.signal }); + } catch (e) { + caught = e; + } + if (caught) { + expect((caught as Error).name).toBe('AbortError'); + } + }); +}); diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts new file mode 100644 index 0000000..653fb15 --- /dev/null +++ b/e2e/auth.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub, OMOPHubError } from '../src/index.js'; +import { e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: auth', () => { + test('constructor throws OMOPHubError when no key is supplied or in env', () => { + const original = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + try { + expect(() => new OMOPHub()).toThrowError(OMOPHubError); + } finally { + if (original !== undefined) process.env.OMOPHUB_API_KEY = original; + } + }); + + runOrSkip('an obviously bad API key gets invalid_api_key from the live API', async () => { + await softThrottle(); + const client = new OMOPHub('oh_invalid_definitely_not_a_real_key_xxx', { + maxRetries: 0, + }); + const { data, error } = await client.vocabularies.list({ pageSize: 1 }); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + // Server may use any of these depending on tier / gating + expect([ + 'invalid_api_key', + 'restricted_api_key', + 'missing_api_key', + 'application_error', + ]).toContain(error?.name); + expect([401, 403]).toContain(error?.statusCode); + }); + + runOrSkip('empty API key string is rejected (treated as missing)', () => { + expect(() => new OMOPHub('')).toThrowError(OMOPHubError); + }); + + runOrSkip('whitespace-only key passes the constructor but server rejects it', async () => { + await softThrottle(); + const client = new OMOPHub(' ', { maxRetries: 0 }); + const { error } = await client.vocabularies.list({ pageSize: 1 }); + expect(error).not.toBeNull(); + expect([401, 403]).toContain(error?.statusCode); + }); +}); diff --git a/e2e/concepts.test.ts b/e2e/concepts.test.ts new file mode 100644 index 0000000..3f0a739 --- /dev/null +++ b/e2e/concepts.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.concepts.get', () => { + runOrSkip('returns the Type 2 diabetes mellitus row by ID', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(data?.domain_id).toBe('Condition'); + expect(data?.standard_concept).toBe('S'); + expect(typeof data?.valid_start_date).toBe('string'); + expect(typeof data?.valid_end_date).toBe('string'); + }); + + runOrSkip('includeSynonyms returns synonyms array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + includeSynonyms: true, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.synonyms)).toBe(true); + }); + + runOrSkip('includeRelationships returns a { parents, children } object', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + includeRelationships: true, + }); + expect(error).toBeNull(); + expect(data?.relationships).toBeTruthy(); + expect(typeof data?.relationships).toBe('object'); + expect(Array.isArray(data?.relationships?.parents)).toBe(true); + expect(Array.isArray(data?.relationships?.children)).toBe(true); + }); + + runOrSkip('all four known fixture concepts resolve and stay typed', async () => { + await softThrottle(); + const client = e2eClient(); + for (const conceptId of Object.values(E2E_CONCEPT_IDS)) { + const { data, error } = await client.concepts.get(conceptId); + await softThrottle(); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(conceptId); + expect(typeof data?.concept_name).toBe('string'); + } + }); + + runOrSkip('vocabRelease pin sends a `vocab_release` query param', async () => { + // We can't easily verify the URL hit the live API; instead confirm + // either a successful resolution OR a structured error (NOT a + // network exception). + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + vocabRelease: '2025.1', + }); + if (error) { + expect(['not_found', 'validation_error', 'application_error']).toContain(error.name); + return; + } + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(typeof data?.concept_name).toBe('string'); + expect((data?.concept_name ?? '').toLowerCase()).toContain('diabetes'); + }); +}); + +describe('e2e: client.concepts.getByCode', () => { + runOrSkip('SNOMED 44054006 → Type 2 diabetes', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.getByCode('SNOMED', '44054006'); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + }); + + runOrSkip('round-trip consistency: get(id) and getByCode(vocab, code) match', async () => { + await softThrottle(); + const client = e2eClient(); + const byId = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + await softThrottle(); + const byCode = await client.concepts.getByCode( + byId.data?.vocabulary_id ?? 'SNOMED', + byId.data?.concept_code ?? '44054006', + ); + expect(byId.error).toBeNull(); + expect(byCode.error).toBeNull(); + expect(byCode.data?.concept_id).toBe(byId.data?.concept_id); + expect(byCode.data?.concept_name).toBe(byId.data?.concept_name); + }); + + runOrSkip( + 'includeRelationships flag flows through and returns { parents, children }', + async () => { + await softThrottle(); + // Slow server path: getByCode + includeRelationships can take >25s. + // No-retry client to stay inside the 60s test timeout; tolerate + // structured timeout as a valid outcome. + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.getByCode('SNOMED', '44054006', { + includeRelationships: true, + }); + if (error) { + expect(['timeout_error', 'connection_error']).toContain(error.name); + return; + } + expect(data?.relationships).toBeTruthy(); + expect(typeof data?.relationships).toBe('object'); + }, + ); +}); + +describe('e2e: client.concepts.batch', () => { + runOrSkip('returns concepts for multiple IDs', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes, E2E_CONCEPT_IDS.hypertension], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThanOrEqual(2); + }); + + runOrSkip('single-id batch is a valid request (length=1, the minimum)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes], + }); + expect(error).toBeNull(); + expect(data?.concepts.length).toBeGreaterThanOrEqual(1); + }); + + runOrSkip('all four known fixtures returned as a batch', async () => { + await softThrottle(); + const client = e2eClient(); + const ids = Object.values(E2E_CONCEPT_IDS); + const { data, error } = await client.concepts.batch({ conceptIds: ids }); + // Tolerate rate-limit errors when run as part of the full e2e + // sweep — the suite stays within healthcare-tier quotas but spike + // bursts can still occur. + if (error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + const returnedIds = new Set((data?.concepts ?? []).map((c) => c.concept_id)); + for (const id of ids) { + const inResults = returnedIds.has(id); + const inFailures = data?.failed_concepts?.some((f) => f.concept_id === id) ?? false; + expect(inResults || inFailures).toBe(true); + } + }); + + runOrSkip('batch with one unknown ID either omits or reports it in failed_concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes, 999_999_999], + }); + if (error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + const returnedIds = (data?.concepts ?? []).map((c) => c.concept_id); + expect(returnedIds).toContain(E2E_CONCEPT_IDS.diabetes); + }); + + runOrSkip('synthetic validation: empty array does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.concepts.batch({ conceptIds: [] }); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic validation: >100 ids does not hit network', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 101 }, (_, i) => 200_000 + i); + const { error } = await client.concepts.batch({ conceptIds: tooMany }); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.concepts.suggest', () => { + runOrSkip('returns concept-summary entries for a query prefix', async () => { + await softThrottle(); + // Slow on first-cache-miss; no-retry client to stay inside test budget. + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.suggest('diab', { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + if (error) { + expect(['timeout_error', 'connection_error']).toContain(error.name); + return; + } + expect(data).toBeTruthy(); + }); +}); + +describe('e2e: client.concepts.related + cross-method consistency', () => { + runOrSkip('related returns a bare array of related concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.related(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + if ((data?.length ?? 0) > 0) { + const first = data?.[0]; + expect(typeof first?.concept_name).toBe('string'); + expect(typeof first?.relationship_id).toBe('string'); + } + }); + + runOrSkip( + 'concepts.relationships and relationships.get produce identical wire results', + async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const a = await client.concepts.relationships(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + const transient = ['timeout_error', 'connection_error', 'rate_limit_exceeded']; + if (a.error && transient.includes(a.error.name)) return; + await softThrottle(); + const b = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + if (b.error && transient.includes(b.error.name)) return; + expect(a.error).toBeNull(); + expect(b.error).toBeNull(); + expect(a.data?.relationships.length).toBe(b.data?.relationships.length); + }, + ); +}); + +describe('e2e: client.concepts.recommended', () => { + runOrSkip('returns recommendations keyed by source concept ID (as string)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.recommended({ + conceptIds: [E2E_CONCEPT_IDS.diabetes], + pageSize: 10, + }); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + expect(typeof data).toBe('object'); + // Keyed by source concept ID as a string + const key = String(E2E_CONCEPT_IDS.diabetes); + const entries = data?.[key]; + expect(Array.isArray(entries)).toBe(true); + if (entries && entries.length > 0) { + expect(typeof entries[0]?.concept_name).toBe('string'); + } + }); +}); diff --git a/e2e/domains.test.ts b/e2e/domains.test.ts new file mode 100644 index 0000000..e90b67f --- /dev/null +++ b/e2e/domains.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClient, e2eClientNoRetry, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.domains.list', () => { + runOrSkip('returns the OMOP domain catalog wrapped under .domains', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.domains.list(); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + const ids = (data?.domains ?? []).map((d) => d.domain_id); + expect(ids).toContain('Condition'); + expect(ids).toContain('Drug'); + }); + + runOrSkip('includeStats returns per-domain stats when requested', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.domains.list({ includeStats: true }); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + }); +}); + +describe('e2e: client.domains.concepts', () => { + runOrSkip('Condition returns paginated standard concepts', async () => { + await softThrottle(); + // standardOnly+Condition is a heavy server query; no-retry client + // keeps us inside the test timeout. Tolerate structured timeout. + const client = e2eClientNoRetry(); + const { data, error, meta } = await client.domains.concepts('Condition', { + pageSize: 5, + standardOnly: true, + }); + if (error) { + expect(['timeout_error', 'connection_error']).toContain(error.name); + return; + } + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThan(0); + expect(typeof meta?.pagination?.total_items).toBe('number'); + for (const c of data?.concepts ?? []) { + expect(c.vocabulary_id).toBeTruthy(); + } + }); + + runOrSkip('Drug + vocabularyIds=["RxNorm"] narrows to RxNorm only', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('Drug', { + vocabularyIds: ['RxNorm'], + pageSize: 10, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + for (const c of data?.concepts ?? []) { + expect(c.vocabulary_id).toBe('RxNorm'); + } + }); + + runOrSkip('Measurement domain returns concepts', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('Measurement', { pageSize: 5 }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(Array.isArray(data?.concepts)).toBe(true); + }); + + runOrSkip('cross-method: domains.concepts and search by domain agree on schema', async () => { + await softThrottle(); + const client = e2eClient(); + const direct = await client.domains.concepts('Condition', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + standardOnly: true, + }); + await softThrottle(); + const viaSearch = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + standardConcept: 'S', + pageSize: 5, + }); + expect(direct.error).toBeNull(); + expect(viaSearch.error).toBeNull(); + for (const c of direct.data?.concepts ?? []) { + expect(c.vocabulary_id).toBe('SNOMED'); + } + for (const c of viaSearch.data?.concepts ?? []) { + if (c.domain_id) expect(c.domain_id).toBe('Condition'); + } + }); +}); diff --git a/e2e/fhir.test.ts b/e2e/fhir.test.ts new file mode 100644 index 0000000..cb7743c --- /dev/null +++ b/e2e/fhir.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'vitest'; +import { omophubFhirUrl } from '../src/index.js'; +import { E2E_CONCEPT_IDS, e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.fhir.resolve', () => { + runOrSkip('SNOMED 44054006 → Type 2 diabetes (direct mapping)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', + }); + expect(error).toBeNull(); + expect(data?.resolution.standard_concept.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.resolution.target_table).toBe('condition_occurrence'); + expect(typeof data?.resolution.mapping_type).toBe('string'); + }); + + runOrSkip('ICD10CM E11.9 → SNOMED (maps-to traversal)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', + }); + expect(error).toBeNull(); + expect(data?.resolution.source_concept.vocabulary_id).toBe('ICD10CM'); + expect(data?.resolution.standard_concept.vocabulary_id).toBe('SNOMED'); + }); + + runOrSkip('nested coding-object form is equivalent to flat form', async () => { + await softThrottle(); + const client = e2eClient(); + const flat = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + }); + await softThrottle(); + const nested = await client.fhir.resolve({ + coding: { system: 'http://snomed.info/sct', code: '44054006' }, + }); + expect(flat.error).toBeNull(); + expect(nested.error).toBeNull(); + expect(nested.data?.resolution.standard_concept.concept_id).toBe( + flat.data?.resolution.standard_concept.concept_id, + ); + }); + + runOrSkip('vocabularyId shortcut bypasses URI lookup', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + vocabularyId: 'ICD10CM', + code: 'E11.9', + }); + expect(error).toBeNull(); + expect(data?.resolution.source_concept.vocabulary_id).toBe('ICD10CM'); + }); + + runOrSkip('includeRecommendations populates the recommendations array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeRecommendations: true, + recommendationsLimit: 3, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.resolution.recommendations)).toBe(true); + }); + + runOrSkip('includeQuality populates mapping_quality (string bucket)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeQuality: true, + }); + expect(error).toBeNull(); + expect(typeof data?.resolution.mapping_quality).toBe('string'); + // Common buckets per the docs — server may add more + expect(['high', 'medium', 'low', 'manual_review']).toContain(data?.resolution.mapping_quality); + }); + + runOrSkip('synthetic: empty code is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolve({ system: 'http://snomed.info/sct', code: '' }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic: coding without code is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolve({ + coding: { system: 'http://snomed.info/sct' }, + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.fhir.resolveBatch', () => { + runOrSkip('2-coding batch reports total/resolved/failed', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + expect(error).toBeNull(); + expect(data?.summary.total).toBe(2); + expect(typeof data?.summary.resolved).toBe('number'); + expect(typeof data?.summary.failed).toBe('number'); + expect((data?.summary.resolved ?? 0) + (data?.summary.failed ?? 0)).toBe(2); + }); + + runOrSkip('single-coding batch (minimum) is valid', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveBatch([ + { system: 'http://snomed.info/sct', code: '44054006' }, + ]); + expect(error).toBeNull(); + expect(data?.summary.total).toBe(1); + }); + + runOrSkip('synthetic: empty array is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolveBatch([]); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic: 101 codings is rejected without network call', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 101 }, () => ({ + system: 'http://snomed.info/sct', + code: '44054006', + })); + const { error } = await client.fhir.resolveBatch(tooMany); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.fhir.resolveCodeableConcept', () => { + runOrSkip('picks the best match across multiple codings', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + expect(error).toBeNull(); + expect(data?.best_match).toBeTruthy(); + expect(Array.isArray(data?.alternatives)).toBe(true); + expect(Array.isArray(data?.unresolved)).toBe(true); + }); + + runOrSkip('synthetic: >20 codings is rejected without network call', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 21 }, () => ({ + system: 'http://snomed.info/sct', + code: '44054006', + })); + const { error } = await client.fhir.resolveCodeableConcept(tooMany); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: standalone helpers', () => { + test('omophubFhirUrl returns the documented base for each FHIR version', () => { + expect(omophubFhirUrl()).toBe('https://fhir.omophub.com/fhir/r4'); + expect(omophubFhirUrl('r4')).toBe('https://fhir.omophub.com/fhir/r4'); + expect(omophubFhirUrl('r4b')).toBe('https://fhir.omophub.com/fhir/r4b'); + expect(omophubFhirUrl('r5')).toBe('https://fhir.omophub.com/fhir/r5'); + expect(omophubFhirUrl('r6')).toBe('https://fhir.omophub.com/fhir/r6'); + }); +}); diff --git a/e2e/hierarchy-relationships-mappings.test.ts b/e2e/hierarchy-relationships-mappings.test.ts new file mode 100644 index 0000000..e2eefda --- /dev/null +++ b/e2e/hierarchy-relationships-mappings.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.hierarchy.get', () => { + runOrSkip('format="flat" returns concepts + paths arrays', async () => { + await softThrottle(); + // Hierarchy traversal is one of the heaviest server queries; use the + // no-retry client to stay inside the test timeout. + const client = e2eClientNoRetry(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + format: 'flat', + maxLevels: 2, + vocabularyIds: ['SNOMED'], + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(data).toBeTruthy(); + }); + + runOrSkip('format="graph" returns nodes + edges arrays', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + format: 'graph', + maxLevels: 2, + }); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + }); + + runOrSkip('maxLevels above the server cap returns a structured validation_error', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 50, // exceeds the server cap of 20 + }); + // Server rejects rather than silently capping — both are valid + // contracts; the SDK just needs to surface the error structurally. + if (error) { + expect(error.name).toBe('validation_error'); + expect(error.statusCode).toBe(400); + expect(data).toBeNull(); + } else { + expect(data).toBeTruthy(); + } + }); +}); + +describe('e2e: client.hierarchy.ancestors', () => { + runOrSkip('returns ancestors of diabetes filtered by vocabulary', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.ancestors(E2E_CONCEPT_IDS.diabetes, { + vocabularyIds: ['SNOMED'], + pageSize: 10, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.ancestors)).toBe(true); + }); + + runOrSkip('includeDistance flag is accepted and ancestors are typed correctly', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.ancestors(E2E_CONCEPT_IDS.diabetes, { + vocabularyIds: ['SNOMED'], + includeDistance: true, + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.ancestors)).toBe(true); + // Distance fields (`level`, `min_levels_of_separation`, + // `max_levels_of_separation`) are server-populated and may not be + // present on every row, so we don't assert their presence — only the + // base ancestor concept shape. + for (const a of data?.ancestors ?? []) { + expect(typeof a.concept_id).toBe('number'); + expect(typeof a.concept_name).toBe('string'); + } + }); +}); + +describe('e2e: client.hierarchy.descendants', () => { + runOrSkip('returns descendants with maxLevels filter', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.descendants(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 2, + pageSize: 10, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.descendants)).toBe(true); + }); + + runOrSkip('domainIds filter restricts descendants to a domain', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.descendants(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 2, + domainIds: ['Condition'], + pageSize: 5, + }); + expect(error).toBeNull(); + for (const d of data?.descendants ?? []) { + if (d.domain_id) expect(d.domain_id).toBe('Condition'); + } + }); +}); + +describe('e2e: client.relationships', () => { + runOrSkip('types() returns the relationship-type catalog', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.relationships.types({ pageSize: 50 }); + expect(error).toBeNull(); + expect(Array.isArray(data?.relationship_types)).toBe(true); + expect(data?.relationship_types.length).toBeGreaterThan(0); + }); + + runOrSkip( + 'get(diabetes) and concepts.relationships(diabetes) hit the same endpoint with matching counts', + async () => { + await softThrottle(); + const client = e2eClient(); + const a = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 10, + vocabularyIds: ['SNOMED'], + }); + await softThrottle(); + const b = await client.concepts.relationships(E2E_CONCEPT_IDS.diabetes, { + pageSize: 10, + vocabularyIds: ['SNOMED'], + }); + expect(a.error).toBeNull(); + expect(b.error).toBeNull(); + expect(a.data?.relationships.length).toBe(b.data?.relationships.length); + }, + ); + + runOrSkip('includeReverse adds reverse relationships', async () => { + await softThrottle(); + const client = e2eClient(); + const without = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + includeReverse: false, + }); + await softThrottle(); + const withReverse = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + includeReverse: true, + }); + expect(without.error).toBeNull(); + expect(withReverse.error).toBeNull(); + // Reverse-inclusive set is a superset + const a = without.meta?.pagination?.total_items ?? 0; + const b = withReverse.meta?.pagination?.total_items ?? 0; + expect(b).toBeGreaterThanOrEqual(a); + }); +}); + +describe('e2e: client.mappings.get', () => { + runOrSkip('returns mappings for diabetes', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('targetVocabulary filter narrows results', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.get(E2E_CONCEPT_IDS.diabetes, { + targetVocabulary: 'ICD10CM', + }); + expect(error).toBeNull(); + for (const m of data?.mappings ?? []) { + if (m.target_vocabulary_id) expect(m.target_vocabulary_id).toBe('ICD10CM'); + } + }); +}); + +describe('e2e: client.mappings.map', () => { + runOrSkip('SNOMED ← ICD10CM E11.9 (sourceCodes variant)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [{ vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('ICD10CM ← OMOP concept IDs (sourceConcepts variant)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.map({ + targetVocabulary: 'ICD10CM', + sourceConcepts: [E2E_CONCEPT_IDS.diabetes], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('mappingType filter is accepted', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [E2E_CONCEPT_IDS.diabetes], + mappingType: 'direct', + }); + expect(error).toBeNull(); + }); + + runOrSkip('synthetic XOR: empty sourceConcepts does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic XOR: empty sourceCodes does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); +}); diff --git a/e2e/pagination.test.ts b/e2e/pagination.test.ts new file mode 100644 index 0000000..290f6f9 --- /dev/null +++ b/e2e/pagination.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHubIteratorError } from '../src/index.js'; +import { e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: pagination behaviour', () => { + runOrSkip('basicAll respects maxPages and reports pagesFetched accurately', async () => { + await softThrottle(); + const client = e2eClient(); + const result = await client.search.basicAll('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + maxPages: 3, + }); + expect(result.errors).toEqual([]); + expect(result.pagesFetched).toBeGreaterThanOrEqual(1); + expect(result.pagesFetched).toBeLessThanOrEqual(3); + // Total items can't exceed maxPages * pageSize + expect(result.data.length).toBeLessThanOrEqual(15); + }); + + runOrSkip('basicIter stops cleanly when caller breaks out of the loop', async () => { + await softThrottle(); + const client = e2eClient(); + let count = 0; + for await (const _ of client.search.basicIter('diabetes', { pageSize: 10 })) { + count++; + if (count >= 7) break; + } + expect(count).toBe(7); + }); + + runOrSkip('basicIter terminates naturally on a query with no results', async () => { + await softThrottle(); + const client = e2eClient(); + let count = 0; + for await (const _ of client.search.basicIter('zzz-no-such-thing-xyz-abc', { + pageSize: 10, + maxPages: 3, + })) { + count++; + } + expect(count).toBe(0); + }); + + runOrSkip('paginated endpoint last page has has_next: false', async () => { + await softThrottle(); + const client = e2eClient(); + // List vocabularies — small enough catalog to reach the last page + const { data, meta, error } = await client.vocabularies.list({ pageSize: 200 }); + expect(error).toBeNull(); + const pagination = meta?.pagination; + expect(pagination).toBeTruthy(); + if (pagination) { + if (pagination.total_items <= pagination.page_size) { + expect(pagination.has_next).toBe(false); + expect(pagination.has_previous).toBe(false); + } + expect(pagination.total_pages).toBe( + Math.max(1, Math.ceil(pagination.total_items / pagination.page_size)), + ); + } + expect(data?.vocabularies.length).toBeLessThanOrEqual(pagination?.page_size ?? 200); + }); + + runOrSkip('pageSize=1 returns exactly one row when results exist', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 1, + }); + expect(error).toBeNull(); + expect(data?.concepts.length).toBeLessThanOrEqual(1); + }); + + runOrSkip('basicIter throws OMOPHubIteratorError when a page fails mid-stream', async () => { + // We can't reliably trigger a mid-stream failure on the live API, so + // we verify the error-class wiring via a deliberately bad client (no + // retries + invalid key) and confirm the iterator throws on FIRST page + await softThrottle(); + const { OMOPHub } = await import('../src/index.js'); + const badClient = new OMOPHub('oh_definitely_bad_key_xxx', { maxRetries: 0 }); + let caught: unknown; + try { + for await (const _ of badClient.search.basicIter('diabetes', { + pageSize: 5, + maxPages: 1, + })) { + // unreachable + } + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + }); + + runOrSkip('basicAll surfaces partial result + errors when a page fails', async () => { + await softThrottle(); + const { OMOPHub } = await import('../src/index.js'); + const badClient = new OMOPHub('oh_definitely_bad_key_xxx', { maxRetries: 0 }); + const result = await badClient.search.basicAll('diabetes', { + pageSize: 5, + maxPages: 2, + }); + expect(result.errors.length).toBeGreaterThanOrEqual(1); + // pagesFetched counts the attempt that failed + expect(result.pagesFetched).toBeGreaterThanOrEqual(1); + }); + + runOrSkip('navigating to page 2 explicitly works when has_next is true', async () => { + await softThrottle(); + const client = e2eClient(); + const first = await client.vocabularies.list({ pageSize: 5, page: 1 }); + if (first.meta?.pagination?.has_next) { + await softThrottle(); + const second = await client.vocabularies.list({ pageSize: 5, page: 2 }); + expect(second.error).toBeNull(); + expect(second.meta?.pagination?.page).toBe(2); + expect(second.meta?.pagination?.has_previous).toBe(true); + } + }); +}); diff --git a/e2e/search.test.ts b/e2e/search.test.ts new file mode 100644 index 0000000..0810d8d --- /dev/null +++ b/e2e/search.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.search.basic', () => { + runOrSkip('returns a normalised SearchResult with concepts array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThan(0); + expect(data?.concepts[0]?.concept_name?.toLowerCase()).toContain('diab'); + }); + + runOrSkip('standardConcept: "S" returns only standard rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + standardConcept: 'S', + pageSize: 10, + }); + expect(error).toBeNull(); + for (const c of data?.concepts ?? []) { + if (c.standard_concept !== undefined && c.standard_concept !== null) { + expect(c.standard_concept).toBe('S'); + } + } + }); + + runOrSkip('exactMatch flag flows through to the wire', async () => { + await softThrottle(); + // Use a no-retry client — exactMatch=true triggers a slower server path + // and full retries would push us past the test timeout. Either we get + // an empty/non-empty success or a structured timeout — both are fine. + const client = e2eClientNoRetry(); + const { data, error } = await client.search.basic('Type 2 diabetes mellitus', { + exactMatch: true, + pageSize: 3, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'validation_error']).toContain(error.name); + } else { + expect(Array.isArray(data?.concepts)).toBe(true); + } + }); + + runOrSkip('basicIter walks at least one page', async () => { + await softThrottle(); + const client = e2eClient(); + const collected: number[] = []; + for await (const c of client.search.basicIter('aspirin', { + vocabularyIds: ['RxNorm'], + pageSize: 5, + maxPages: 1, + })) { + collected.push(c.concept_id); + } + expect(collected.length).toBeGreaterThan(0); + }); + + runOrSkip('basicAll aggregates with maxPages cap', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 10, + maxPages: 2, + }); + expect(errors).toEqual([]); + expect(pagesFetched).toBeGreaterThanOrEqual(1); + expect(pagesFetched).toBeLessThanOrEqual(2); + expect(data.length).toBeGreaterThan(0); + }); +}); + +describe('e2e: client.search.advanced', () => { + runOrSkip('hits POST /search/advanced and returns normalised concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.advanced('diabetes type 2', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + }); + + runOrSkip('relationshipFilters use camelCase keys (server sees snake_case)', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.advanced('diabetes', { + relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: E2E_CONCEPT_IDS.diabetes }], + pageSize: 3, + }); + if (error) { + // A 400 here would indicate the camelCase → snake_case translation is broken + expect(error.statusCode).not.toBe(400); + } + }); +}); + +describe('e2e: client.search.autocomplete', () => { + runOrSkip('returns suggestions wrapped under .suggestions', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('diab', { pageSize: 5 }); + expect(error).toBeNull(); + expect(data?.query).toBe('diab'); + expect(Array.isArray(data?.suggestions)).toBe(true); + expect(data?.suggestions.length).toBeGreaterThan(0); + const first = data?.suggestions[0]; + expect(typeof first?.suggestion.concept_id).toBe('number'); + expect(typeof first?.suggestion.concept_name).toBe('string'); + }); + + runOrSkip('echoes the query field with vocabulary filter applied', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('aspirin', { + vocabularyIds: ['RxNorm'], + pageSize: 3, + }); + expect(error).toBeNull(); + expect(data?.query).toBe('aspirin'); + }); +}); + +describe('e2e: client.search.semantic', () => { + runOrSkip('returns normalised results array regardless of server shape', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.semantic('high blood sugar', { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.results)).toBe(true); + }); + + runOrSkip('similarity scores fall within [0, 1]', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.semantic('blood pressure', { + threshold: 0.5, + pageSize: 5, + }); + expect(error).toBeNull(); + for (const r of data?.results ?? []) { + expect(r.similarity_score).toBeGreaterThanOrEqual(0); + expect(r.similarity_score).toBeLessThanOrEqual(1); + } + }); + + runOrSkip('semanticIter terminates cleanly and yields well-shaped items', async () => { + await softThrottle(); + const client = e2eClient(); + // The test asserts (1) iteration doesn't throw and (2) each yielded + // item carries a numeric concept_id. Server may return zero results + // for some queries, so we don't assert a count. + for await (const r of client.search.semanticIter('chest pain', { + pageSize: 5, + maxPages: 1, + })) { + expect(typeof r.concept_id).toBe('number'); + } + }); +}); + +describe('e2e: client.search.bulkBasic', () => { + runOrSkip('returns a bare array, one entry per search_id', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED', 'RxNorm'], page_size: 3 } }, + ); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length).toBe(3); + const ids = (data ?? []).map((r) => r.search_id); + expect(new Set(ids)).toEqual(new Set(['q1', 'q2', 'q3'])); + for (const item of data ?? []) { + expect(['completed', 'failed']).toContain(item.status); + expect(Array.isArray(item.results)).toBe(true); + } + }); +}); + +describe('e2e: client.search.bulkSemantic', () => { + runOrSkip('returns a wrapper with results + aggregate counts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart attack' }, + { search_id: 's2', query: 'high blood pressure' }, + ], + { defaults: { threshold: 0.4, page_size: 3 } }, + ); + expect(error).toBeNull(); + expect(typeof data).toBe('object'); + expect(Array.isArray(data?.results)).toBe(true); + expect(data?.total_searches).toBe(2); + expect(typeof data?.completed_count).toBe('number'); + expect(typeof data?.failed_count).toBe('number'); + }); +}); + +describe('e2e: client.search.similar (XOR variants)', () => { + runOrSkip('similar by conceptId returns ranked similar concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptId: E2E_CONCEPT_IDS.diabetes, + algorithm: 'hybrid', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + expect(typeof data?.search_metadata?.algorithm_used).toBe('string'); + }); + + runOrSkip('similar by conceptName works for known names', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptName: 'Type 2 diabetes mellitus', + algorithm: 'semantic', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + }); + + runOrSkip('similar by free-text query (no collision with PerCallOptions.query)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + query: 'elevated blood sugar', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + }); + + runOrSkip('algorithm: "lexical" returns text-based matches', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptName: 'diabetes', + algorithm: 'lexical', + similarityThreshold: 0.3, + pageSize: 5, + }); + expect(error).toBeNull(); + if (data?.search_metadata?.algorithm_used) { + expect(['lexical', 'hybrid', 'semantic']).toContain(data.search_metadata.algorithm_used); + } + }); +}); diff --git a/e2e/server-errors.test.ts b/e2e/server-errors.test.ts new file mode 100644 index 0000000..b24cb92 --- /dev/null +++ b/e2e/server-errors.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClientNoRetry, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: server-side errors', () => { + runOrSkip('concepts.get with a non-existent ID returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.get(999_999_999); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + expect(error?.statusCode).toBe(404); + }); + + runOrSkip('concepts.getByCode with an unknown code returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.getByCode('SNOMED', 'DEFINITELY-NOT-A-CODE'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); + + runOrSkip('vocabularies.get with an unknown vocabulary returns an error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.vocabularies.get('THIS_VOCAB_DOES_NOT_EXIST'); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + expect([400, 404]).toContain(error?.statusCode); + }); + + runOrSkip('domains.concepts with an unknown domain returns an empty concepts array', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('NotARealDomain'); + // Server returns 200 OK with `{ concepts: [] }` for unknown domains + // rather than a 404. Treat empty as the "no matches" signal. + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBe(0); + }); + + runOrSkip('hierarchy.ancestors for non-existent concept returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { error } = await client.hierarchy.ancestors(999_999_999); + expect(error).not.toBeNull(); + expect([400, 404]).toContain(error?.statusCode); + }); + + runOrSkip('mappings.get for non-existent concept returns not_found or empty', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.mappings.get(999_999_999); + // Either: 404, OR a 200 with empty mappings array — both are acceptable + if (error) { + expect([400, 404]).toContain(error.statusCode); + } else { + expect(Array.isArray(data?.mappings)).toBe(true); + } + }); + + runOrSkip('fhir.resolve with unknown system URI surfaces a structured error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.fhir.resolve({ + system: 'http://example.invalid/code-system', + code: '12345', + }); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + expect(typeof error?.name).toBe('string'); + expect(typeof error?.message).toBe('string'); + }); + + runOrSkip('error responses preserve x-request-id for support triage', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { error, headers } = await client.concepts.get(999_999_999); + expect(error).not.toBeNull(); + // requestId must surface on BOTH the error object AND the raw + // headers, and the two must match — that's the support-triage contract. + expect(typeof error?.requestId).toBe('string'); + expect((error?.requestId ?? '').length).toBeGreaterThan(0); + const headerRequestId = headers?.['x-request-id']; + expect(typeof headerRequestId).toBe('string'); + expect((headerRequestId ?? '').length).toBeGreaterThan(0); + expect(error?.requestId).toBe(headerRequestId); + }); + + runOrSkip('200-OK error responses (empty matches) return success, not error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.search.basic('zzz-no-such-concept-aaa-xyz', { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + // Empty matches is a successful query with zero results + expect(data?.concepts.length).toBe(0); + }); +}); diff --git a/e2e/url-encoding.test.ts b/e2e/url-encoding.test.ts new file mode 100644 index 0000000..9eae71d --- /dev/null +++ b/e2e/url-encoding.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: URL safety + encoding', () => { + runOrSkip('getByCode with a code containing a dot round-trips correctly', async () => { + await softThrottle(); + const client = e2eClient(); + // ICD10CM codes contain dots — verify the path segment encodes them + // without truncation or fragment-stripping. + const { data, error } = await client.concepts.getByCode('ICD10CM', 'E11.9'); + expect(error).toBeNull(); + expect(data?.concept_code).toBe('E11.9'); + expect(data?.vocabulary_id).toBe('ICD10CM'); + }); + + runOrSkip('queries with spaces are correctly encoded', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('type 2 diabetes mellitus', { + pageSize: 3, + vocabularyIds: ['SNOMED'], + }); + expect(error).toBeNull(); + expect(data?.concepts.length ?? 0).toBeGreaterThan(0); + }); + + runOrSkip( + 'queries with commas inside vocabularyIds are joined with comma not duplicated', + async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + // Results from multiple vocabularies indicate the array was joined, not + // dropped, and not URL-mangled + if (data && data.concepts.length > 0) { + const vocabs = new Set(data.concepts.map((c) => c.vocabulary_id)); + // We requested two vocabs; allow either to be present + const intersection = ['SNOMED', 'ICD10CM'].filter((v) => vocabs.has(v)); + expect(intersection.length).toBeGreaterThan(0); + } + }, + ); + + runOrSkip('queries with ampersand do not bleed into URL params', async () => { + await softThrottle(); + const client = e2eClient(); + // If `&` isn't encoded, the server would treat the rest as a separate param + const { error } = await client.search.basic('headache & nausea', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('Unicode queries (Cyrillic) round-trip without corruption', async () => { + await softThrottle(); + const client = e2eClient(); + // The query may have zero matches — the important thing is no encoding error + const { error } = await client.search.basic('диабет', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('Unicode queries (Japanese) round-trip without corruption', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('糖尿病', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('queries with + character are encoded (not treated as space)', async () => { + await softThrottle(); + const client = e2eClient(); + // `vitamin B+12` shouldn't become `vitamin B 12` + const { error } = await client.search.basic('vitamin B+12', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('queries with question mark do not bleed into URL params', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('what is diabetes?', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('hash characters do not get stripped as fragment', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('insulin #1', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('autocomplete query echo matches input including special chars', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('diab', { pageSize: 3 }); + expect(error).toBeNull(); + expect(data?.query).toBe('diab'); + }); +}); diff --git a/e2e/vocabularies.test.ts b/e2e/vocabularies.test.ts new file mode 100644 index 0000000..68ba2a6 --- /dev/null +++ b/e2e/vocabularies.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'vitest'; +import { E2E_CONCEPT_IDS, e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.vocabularies', () => { + runOrSkip('list() returns at least SNOMED', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error, headers, meta } = await client.vocabularies.list({ pageSize: 200 }); + // First test in the suite — server cold cache can spike latency. + if (error?.name === 'timeout_error' || error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + expect(headers).toBeTruthy(); + expect(Array.isArray(data?.vocabularies)).toBe(true); + const ids = (data?.vocabularies ?? []).map((v) => v.vocabulary_id); + expect(ids).toContain('SNOMED'); + expect(typeof meta?.pagination?.total_items).toBe('number'); + }); + + runOrSkip('list() exposes total_items, total_pages, has_next correctly', async () => { + await softThrottle(); + const client = e2eClient(); + const { meta, error } = await client.vocabularies.list({ pageSize: 10 }); + expect(error).toBeNull(); + const p = meta?.pagination; + expect(p).toBeTruthy(); + if (p) { + expect(p.page).toBe(1); + expect(p.page_size).toBe(10); + expect(p.total_items).toBeGreaterThan(0); + expect(p.total_pages).toBe(Math.max(1, Math.ceil(p.total_items / p.page_size))); + if (p.total_items > p.page_size) expect(p.has_next).toBe(true); + expect(p.has_previous).toBe(false); + } + }); + + runOrSkip('list() with includeStats embeds per-vocab statistics', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.list({ + pageSize: 5, + includeStats: true, + }); + expect(error).toBeNull(); + // `stats` is the includeStats-gated field on `VocabularySummary`; + // it must be populated on at least one returned vocab when the flag is set. + const someHaveStats = (data?.vocabularies ?? []).some( + (v) => v.stats !== undefined && typeof v.stats.total_concepts === 'number', + ); + expect(someHaveStats).toBe(true); + }); + + runOrSkip('list() without includeStats omits the stats field', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.list({ pageSize: 5 }); + expect(error).toBeNull(); + for (const v of data?.vocabularies ?? []) { + expect(v.stats).toBeUndefined(); + } + }); + + runOrSkip('get("SNOMED") returns vocabulary metadata', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.get('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(typeof data?.vocabulary_name).toBe('string'); + }); + + runOrSkip('get("RxNorm") works for drug vocabulary', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.get('RxNorm'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('RxNorm'); + }); + + runOrSkip('stats("SNOMED") returns concept counts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.stats('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(typeof data?.total_concepts).toBe('number'); + expect(data?.total_concepts ?? 0).toBeGreaterThan(0); + }); + + runOrSkip('domainStats("SNOMED", "Condition") returns a structured payload', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.domainStats('SNOMED', 'Condition'); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + }); + + runOrSkip('domains() returns the vocab-scoped domain catalog', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.domains(); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + const ids = (data?.domains ?? []).map((d) => d.domain_id); + expect(ids).toContain('Condition'); + }); + + runOrSkip('conceptClasses() returns a bare array of concept-class rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.conceptClasses(); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + expect(typeof data?.[0]?.concept_class_id).toBe('string'); + }); + + runOrSkip('concepts(SNOMED) returns a bare array of concept rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error, meta } = await client.vocabularies.concepts('SNOMED', { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + expect(typeof meta?.pagination?.total_items).toBe('number'); + }); + + runOrSkip('concepts(SNOMED, search="diabetes") narrows results', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.concepts('SNOMED', { + search: 'diabetes', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + }); +}); + +describe('e2e: cross-SDK parity', () => { + runOrSkip('known concept IDs resolve to expected names', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.concept_name?.toLowerCase()).toContain('diabetes'); + }); +}); diff --git a/package.json b/package.json index 4fada7d..58a1a23 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "lint": "biome check src/ test/", - "lint:fix": "biome check --write src/ test/", - "typecheck": "tsc --noEmit", - "format": "biome format --write src/ test/", - "format:check": "biome format --check src/ test/", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "lint": "biome check src/ test/ e2e/", + "lint:fix": "biome check --write src/ test/ e2e/", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.e2e.json", + "format": "biome format --write src/ test/ e2e/", + "format:check": "biome format --check src/ test/ e2e/", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..b5ae6ac --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,39 @@ +import { OMOPHubError } from '../errors.js'; + +const ENV_VAR = 'OMOPHUB_API_KEY'; + +/** + * Returns the API key from `process.env.OMOPHUB_API_KEY`, or `undefined` + * if unset or running outside a Node-like runtime (Cloudflare Workers, + * Vercel Edge, browsers without polyfills). + */ +export function getApiKey(): string | undefined { + if (typeof process === 'undefined' || !process.env) return undefined; + return process.env[ENV_VAR]; +} + +/** + * Stores the API key in `process.env.OMOPHUB_API_KEY`. Throws + * `OMOPHubError` in runtimes without a writable `process.env` — that's + * an SDK misuse signal, not a network error, so it deliberately throws + * rather than returning the discriminated error shape. + */ +export function setApiKey(key: string): void { + if (typeof process === 'undefined' || !process.env) { + throw new OMOPHubError('setApiKey requires a Node-like runtime with a writable process.env.'); + } + // Reject empty / whitespace-only keys so `setApiKey` stays consistent + // with `hasApiKey` (which returns false for empty strings). + if (typeof key !== 'string' || key.trim().length === 0) { + throw new OMOPHubError('setApiKey requires a non-empty key string.'); + } + process.env[ENV_VAR] = key; +} + +/** + * Returns `true` iff `OMOPHUB_API_KEY` is set and non-empty. + */ +export function hasApiKey(): boolean { + const k = getApiKey(); + return typeof k === 'string' && k.length > 0; +} diff --git a/src/client.ts b/src/client.ts index 7830284..196e30a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,6 +14,7 @@ import { unwrapEnvelope } from './common/utils/unwrap-envelope.js'; import { Concepts } from './concepts/concepts.js'; import { Domains } from './domains/domains.js'; import { OMOPHubError } from './errors.js'; +import { Fhir } from './fhir/fhir.js'; import { Hierarchy } from './hierarchy/hierarchy.js'; import type { Response as OMOPHubResponse } from './interfaces.js'; import { Mappings } from './mappings/mappings.js'; @@ -69,6 +70,7 @@ export class OMOPHub { readonly concepts: Concepts; readonly domains: Domains; + readonly fhir: Fhir; readonly hierarchy: Hierarchy; readonly mappings: Mappings; readonly relationships: Relationships; @@ -102,6 +104,7 @@ export class OMOPHub { this.concepts = new Concepts(this); this.domains = new Domains(this); + this.fhir = new Fhir(this); this.hierarchy = new Hierarchy(this); this.mappings = new Mappings(this); this.relationships = new Relationships(this); diff --git a/src/concepts/interfaces/concept.ts b/src/concepts/interfaces/concept.ts index 096c8b4..1f6cc4e 100644 --- a/src/concepts/interfaces/concept.ts +++ b/src/concepts/interfaces/concept.ts @@ -2,9 +2,9 @@ * OMOP concept payload shapes. Field names are snake_case to match the * wire format exactly — no client-side translation on response bodies. * - * Shapes are derived from the Python SDK TypedDicts; nested fields like - * `relationships` and `hierarchy` only appear when the matching `include_*` - * flag is set on the request. + * Shapes are derived from live-API inspection during the e2e validation + * pass; nested fields like `relationships` and `hierarchy` only appear + * when the matching `include_*` flag is set on the request. */ export interface Synonym { @@ -19,6 +19,11 @@ export interface ConceptSummary { concept_code: string; } +/** + * Full relationship row returned by the dedicated relationships + * endpoints (`GET /concepts/{id}/relationships`, `relationships.get(id)`, + * `concepts.relationships(id)`). Carries the target concept's full metadata. + */ export interface ConceptRelationship { relationship_id: string; relationship_name?: string; @@ -33,6 +38,18 @@ export interface ConceptRelationship { invalid_reason?: string | null; } +/** + * Compact relationship row attached to `Concept.relationships.parents` + * and `.children` when `concepts.get(id, { includeRelationships: true })` + * is called. Simpler than `ConceptRelationship` — only carries the + * target concept ID, name, and relationship type. + */ +export interface ConceptRelationshipNode { + concept_id: number; + concept_name: string; + relationship_id: string; +} + export interface ConceptHierarchyNode { concept_id: number; concept_name: string; @@ -53,22 +70,42 @@ export interface Concept extends ConceptSummary { valid_start_date: string; valid_end_date: string; invalid_reason?: string | null; + /** Server-flagged boolean equivalent to `invalid_reason === null`. */ + is_valid?: boolean; + /** Server-flagged boolean equivalent to `standard_concept === 'S'`. */ + is_standard?: boolean; + /** Server-flagged boolean equivalent to `standard_concept === 'C'`. */ + is_classification?: boolean; synonyms?: Synonym[]; - relationships?: ConceptRelationship[]; + /** + * Populated when `includeRelationships: true`. The server returns a + * `{ parents, children }` shape — NOT a flat `ConceptRelationship[]`. + * Use `relationships.get(conceptId)` for the richer paginated view. + */ + relationships?: { + parents?: ConceptRelationshipNode[]; + children?: ConceptRelationshipNode[]; + }; hierarchy?: { ancestors?: ConceptHierarchyNode[]; descendants?: ConceptHierarchyNode[]; }; } +/** + * `concepts.related(id)` result row. Each item is a related concept + * with relationship metadata (`relationship_id`, `relationship_score`, + * `relationship_distance`). + */ export interface RelatedConcept extends ConceptSummary { - relatedness_score: number; - relatedness_type: string; - hierarchical_score?: number; - semantic_score?: number; - co_occurrence_score?: number; - mapping_score?: number; - explanation?: string; + vocabulary_name?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + relationship_id: string; + relationship_name?: string; + relationship_score?: number; + relationship_distance?: number; } export interface BatchConceptResult { @@ -90,19 +127,38 @@ export interface ConceptSuggestion { vocabulary_id: string; } -export interface RelatedConceptsResult { - related_concepts: RelatedConcept[]; -} +/** + * `concepts.related` returns a bare array of related concepts. + * Aliased as a type so the resource method's return looks named. + */ +export type RelatedConceptsResult = RelatedConcept[]; export interface ConceptRelationshipsResult { relationships: ConceptRelationship[]; } -export interface ConceptRecommendation { - source_concept_id: number; - recommendations: RelatedConcept[]; +/** + * Single entry in a `concepts.recommended` result group. + */ +export interface RecommendedConceptEntry extends ConceptSummary { + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + invalid_reason?: string | null; + valid_start_date?: string; + valid_end_date?: string; + relationship_id?: string; } -export interface RecommendedConceptsResult { - recommendations: ConceptRecommendation[]; -} +/** + * `concepts.recommended` groups its output **keyed by source concept ID** + * (as a string, per the JSON shape). Iterate via `Object.entries(data)`. + * + * ```ts + * const { data } = await client.concepts.recommended({ conceptIds: [201826] }); + * for (const [sourceId, entries] of Object.entries(data ?? {})) { + * console.log(`Recommendations for ${sourceId}:`, entries); + * } + * ``` + */ +export type RecommendedConceptsResult = Record; diff --git a/src/concepts/interfaces/index.ts b/src/concepts/interfaces/index.ts index 20445c3..462a383 100644 --- a/src/concepts/interfaces/index.ts +++ b/src/concepts/interfaces/index.ts @@ -3,11 +3,12 @@ export type { BatchConceptResult, Concept, ConceptHierarchyNode, - ConceptRecommendation, ConceptRelationship, + ConceptRelationshipNode, ConceptRelationshipsResult, ConceptSuggestion, ConceptSummary, + RecommendedConceptEntry, RecommendedConceptsResult, RelatedConcept, RelatedConceptsResult, diff --git a/src/domains/domains.ts b/src/domains/domains.ts index 7575ac3..85bd8a1 100644 --- a/src/domains/domains.ts +++ b/src/domains/domains.ts @@ -1,9 +1,7 @@ import type { OMOPHub } from '../client.js'; import type { GetOptions } from '../common/interfaces/get-options.js'; -import type { PaginatedData } from '../common/interfaces/pagination.js'; -import type { ConceptSummary } from '../concepts/interfaces/concept.js'; import type { Response as OMOPHubResponse } from '../interfaces.js'; -import type { Domain } from './interfaces/domain.js'; +import type { DomainConceptsResult, ListDomainsResult } from './interfaces/domain.js'; import type { DomainConceptsOptions } from './interfaces/domain-concepts-options.js'; import type { ListDomainsOptions } from './interfaces/list-domains-options.js'; @@ -14,16 +12,18 @@ export class Domains { * List all OMOP domains. Distinct from `vocabularies.domains()` — * this endpoint (`/domains`) returns the canonical domain catalog, * while the vocabularies version returns domains scoped to vocabulary - * usage. + * usage. Wrapped under `domains`. * * Not paginated server-side (Python and R SDKs both call this endpoint * without page/page_size). Returns the full catalog in a single call. * * @see https://docs.omophub.com/api-reference/domains/list */ - async list(options: ListDomainsOptions & GetOptions = {}): Promise> { + async list( + options: ListDomainsOptions & GetOptions = {}, + ): Promise> { const { signal, headers, query, ...flags } = options; - return this.client.get('/domains', { + return this.client.get('/domains', { signal, headers, query: { ...flags, ...query }, @@ -31,16 +31,17 @@ export class Domains { } /** - * List the concepts that belong to a specific domain. + * List the concepts that belong to a specific domain. Wrapped under + * `concepts`; pagination metadata on outer `Response.meta`. * * @see https://docs.omophub.com/api-reference/domains/concepts */ async concepts( domainId: string, options: DomainConceptsOptions & GetOptions = {}, - ): Promise>> { + ): Promise> { const { signal, headers, query, ...flags } = options; - return this.client.get>( + return this.client.get( `/domains/${encodeURIComponent(domainId)}/concepts`, { signal, headers, query: { ...flags, ...query } }, ); diff --git a/src/domains/interfaces/domain.ts b/src/domains/interfaces/domain.ts index 4252bd5..ed25f9b 100644 --- a/src/domains/interfaces/domain.ts +++ b/src/domains/interfaces/domain.ts @@ -20,3 +20,19 @@ export interface Domain extends DomainSummary { category?: string; description?: string; } + +/** + * `GET /domains` returns the catalog wrapped under `domains` (not a bare + * array). The endpoint is not paginated server-side. + */ +export interface ListDomainsResult { + domains: Domain[]; +} + +/** + * `GET /domains/{id}/concepts` — concepts wrapped under `concepts`, + * pagination on outer `Response.meta`. + */ +export interface DomainConceptsResult { + concepts: import('../../concepts/interfaces/concept.js').ConceptSummary[]; +} diff --git a/src/domains/interfaces/index.ts b/src/domains/interfaces/index.ts index 17587e6..0cf207b 100644 --- a/src/domains/interfaces/index.ts +++ b/src/domains/interfaces/index.ts @@ -1,3 +1,9 @@ -export type { Domain, DomainStats, DomainSummary } from './domain.js'; +export type { + Domain, + DomainConceptsResult, + DomainStats, + DomainSummary, + ListDomainsResult, +} from './domain.js'; export type { DomainConceptsOptions } from './domain-concepts-options.js'; export type { ListDomainsOptions } from './list-domains-options.js'; diff --git a/src/fhir/fhir-url.ts b/src/fhir/fhir-url.ts new file mode 100644 index 0000000..ee67c3f --- /dev/null +++ b/src/fhir/fhir-url.ts @@ -0,0 +1,18 @@ +export type FhirVersion = 'r4' | 'r4b' | 'r5' | 'r6'; + +/** + * Returns the URL of OMOPHub's hosted FHIR Terminology Service for the + * given version. No client / API key required — this is a pointer for + * users who want to hit the raw FHIR endpoints (CodeSystem/$lookup, + * ValueSet/$expand, etc.) with their own FHIR client library. + * + * ```ts + * import { omophubFhirUrl } from '@omophub/omophub-node'; + * + * const base = omophubFhirUrl('r4'); + * // → https://fhir.omophub.com/fhir/r4 + * ``` + */ +export function omophubFhirUrl(version: FhirVersion = 'r4'): string { + return `https://fhir.omophub.com/fhir/${version}`; +} diff --git a/src/fhir/fhir.ts b/src/fhir/fhir.ts new file mode 100644 index 0000000..2080945 --- /dev/null +++ b/src/fhir/fhir.ts @@ -0,0 +1,130 @@ +import type { OMOPHub } from '../client.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { + Coding, + FhirBatchResult, + FhirCodeableConceptResult, + FhirResolveResult, +} from './interfaces/fhir.js'; +import type { ResolveBatchOptions } from './interfaces/resolve-batch-options.js'; +import type { ResolveCodeableConceptOptions } from './interfaces/resolve-codeable-concept-options.js'; +import type { ResolveOptions } from './interfaces/resolve-options.js'; + +export class Fhir { + constructor(private readonly client: OMOPHub) {} + + /** + * Resolve a single FHIR coding to its OMOP standard concept. + * + * Accepts two equivalent input forms: + * + * ```ts + * // Flat form: + * client.fhir.resolve({ system: 'http://snomed.info/sct', code: '44054006' }); + * + * // Nested-coding form: + * client.fhir.resolve({ coding: { system: 'http://snomed.info/sct', code: '44054006' } }); + * ``` + * + * Flat fields take precedence when both forms are supplied. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve + */ + async resolve( + options: ResolveOptions & PostOptions, + ): Promise> { + const hasCodingObj = + options.coding !== undefined && + options.coding !== null && + typeof options.coding === 'object' && + !Array.isArray(options.coding) && + typeof options.coding.code === 'string' && + options.coding.code.length > 0; + const hasFlatCode = typeof options.code === 'string' && options.code.length > 0; + if (!hasCodingObj && !hasFlatCode) { + return syntheticError( + 'missing_required_field', + 'Provide a `coding` object with a `code`, or supply `code` (with optional `system`) at the top level.', + ); + } + + const { signal, headers, query, idempotencyKey, coding, ...flatFields } = options; + // Merge order: coding first, flat overrides — lets callers spread a + // FHIR object and then patch specific fields. + const merged = coding ? { ...coding, ...stripUndefined(flatFields) } : flatFields; + const body = toSnakeCaseKeys(merged); + return this.client.post('/fhir/resolve', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Resolve a batch of up to 100 FHIR codings in a single request. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve-batch + */ + async resolveBatch( + codings: Coding[], + options: ResolveBatchOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(codings) || codings.length < 1 || codings.length > 100) { + return syntheticError( + 'validation_error', + '`codings` must be an array of 1–100 entries.', + ); + } + const { signal, headers, query, idempotencyKey, ...rest } = options; + const body = toSnakeCaseKeys({ codings, ...rest }); + return this.client.post('/fhir/resolve/batch', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Resolve a FHIR `CodeableConcept` (up to 20 codings) by picking the + * best-matching OMOP standard concept across the supplied codings. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve-codeable-concept + */ + async resolveCodeableConcept( + coding: Coding[], + options: ResolveCodeableConceptOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(coding) || coding.length < 1 || coding.length > 20) { + return syntheticError( + 'validation_error', + '`coding` must be an array of 1–20 entries.', + ); + } + const { signal, headers, query, idempotencyKey, ...rest } = options; + const body = toSnakeCaseKeys({ coding, ...rest }); + return this.client.post('/fhir/resolve/codeable-concept', body, { + signal, + headers, + query, + idempotencyKey, + }); + } +} + +/** + * Strips keys whose value is `undefined`. Used when merging flat fields + * onto a coding object so an unset `system` doesn't blow away the + * coding's `system`. + */ +function stripUndefined>(obj: T): Partial { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out as Partial; +} diff --git a/src/fhir/interfaces/fhir.ts b/src/fhir/interfaces/fhir.ts new file mode 100644 index 0000000..7a6122b --- /dev/null +++ b/src/fhir/interfaces/fhir.ts @@ -0,0 +1,80 @@ +/** + * FHIR `Coding` shape, structurally compatible with the FHIR JSON spec + * (camelCase). The SDK converts to snake_case (`user_selected`, + * `vocabulary_id`) at the wire boundary via `toSnakeCaseKeys`. + */ +export interface Coding { + system?: string; + code?: string; + display?: string; + /** Whether this coding was explicitly chosen by the user (FHIR spec). */ + userSelected?: boolean; + /** OMOPHub extension — pin the OMOP vocabulary_id rather than letting + * the API infer it from `system`. */ + vocabularyId?: string; +} + +export interface CodeableConcept { + coding?: Coding[]; + text?: string; +} + +/** + * One row of the OMOP concept table as returned by the FHIR resolver. + * Snake_case to match the wire. + */ +export interface ResolvedConcept { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; +} + +export interface RecommendedConceptOutput extends ResolvedConcept { + similarity_score?: number; + relationship_type?: string; +} + +export interface FhirResolution { + source_concept: ResolvedConcept; + standard_concept: ResolvedConcept; + value_as_concept?: ResolvedConcept; + value_target_field?: string; + mapping_type: string; + target_table: string; + domain_resource_alignment?: string; + similarity_score?: number; + /** Quality bucket: typically `'high'`, `'medium'`, `'low'`, or `'manual_review'`. */ + mapping_quality?: string; + quality_note?: string; + alternative_standard_concepts?: ResolvedConcept[]; + recommendations?: RecommendedConceptOutput[]; + concept_map_id?: string; + mapping_note?: string; +} + +export interface FhirResolveResult { + input: Record; + resolution: FhirResolution; +} + +export interface FhirBatchSummary { + total: number; + resolved: number; + failed: number; +} + +export interface FhirBatchResult { + results: FhirResolveResult[]; + summary: FhirBatchSummary; +} + +export interface FhirCodeableConceptResult { + input: Record; + best_match?: FhirResolution; + alternatives: FhirResolution[]; + unresolved: Record[]; +} diff --git a/src/fhir/interfaces/index.ts b/src/fhir/interfaces/index.ts new file mode 100644 index 0000000..0641017 --- /dev/null +++ b/src/fhir/interfaces/index.ts @@ -0,0 +1,14 @@ +export type { + CodeableConcept, + Coding, + FhirBatchResult, + FhirBatchSummary, + FhirCodeableConceptResult, + FhirResolution, + FhirResolveResult, + RecommendedConceptOutput, + ResolvedConcept, +} from './fhir.js'; +export type { ResolveBatchOptions } from './resolve-batch-options.js'; +export type { ResolveCodeableConceptOptions } from './resolve-codeable-concept-options.js'; +export type { ResolveOptions } from './resolve-options.js'; diff --git a/src/fhir/interfaces/resolve-batch-options.ts b/src/fhir/interfaces/resolve-batch-options.ts new file mode 100644 index 0000000..472ba86 --- /dev/null +++ b/src/fhir/interfaces/resolve-batch-options.ts @@ -0,0 +1,8 @@ +export interface ResolveBatchOptions { + resourceType?: string; + includeRecommendations?: boolean; + /** Max recommendations per coding (1–20). Default 5 at the API. */ + recommendationsLimit?: number; + includeQuality?: boolean; + onUnmapped?: 'error' | 'sentinel'; +} diff --git a/src/fhir/interfaces/resolve-codeable-concept-options.ts b/src/fhir/interfaces/resolve-codeable-concept-options.ts new file mode 100644 index 0000000..7487102 --- /dev/null +++ b/src/fhir/interfaces/resolve-codeable-concept-options.ts @@ -0,0 +1,9 @@ +export interface ResolveCodeableConceptOptions { + /** Optional human-readable description of the CodeableConcept. */ + text?: string; + resourceType?: string; + includeRecommendations?: boolean; + recommendationsLimit?: number; + includeQuality?: boolean; + onUnmapped?: 'error' | 'sentinel'; +} diff --git a/src/fhir/interfaces/resolve-options.ts b/src/fhir/interfaces/resolve-options.ts new file mode 100644 index 0000000..0613a32 --- /dev/null +++ b/src/fhir/interfaces/resolve-options.ts @@ -0,0 +1,34 @@ +import type { Coding } from './fhir.js'; + +/** + * Options for `fhir.resolve()`. Accepts either flat fields + * (`{ system, code, display, vocabularyId }`) or a nested `coding` object + * — mirrors the Python SDK's `_extract_coding`. The flat form takes + * precedence when both are supplied. + * + * At minimum, `code` (or `coding.code`) is required. + */ +export interface ResolveOptions { + // Flat-form fields + system?: string; + code?: string; + display?: string; + vocabularyId?: string; + + // Nested-form + coding?: Coding; + + // Common + /** FHIR resource type for context-aware mapping (e.g. `'Condition'`, `'Observation'`). */ + resourceType?: string; + /** Include Phoebe-style recommendations alongside the resolution. */ + includeRecommendations?: boolean; + /** Max recommendations (1–20). Default 5 at the API. */ + recommendationsLimit?: number; + includeQuality?: boolean; + /** + * `'error'` (default) returns a 404 for unmapped codings; `'sentinel'` + * returns a `concept_id: 0` row so batch flows can keep going. + */ + onUnmapped?: 'error' | 'sentinel'; +} diff --git a/src/index.ts b/src/index.ts index 3102789..3a70466 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { getApiKey, hasApiKey, setApiKey } from './auth/auth.js'; export { OMOPHub, type OMOPHubOptions } from './client.js'; export type { ApiEnvelope, @@ -23,14 +24,15 @@ export type { BatchConceptsOptions, Concept, ConceptHierarchyNode, - ConceptRecommendation, ConceptRelationship, + ConceptRelationshipNode, ConceptRelationshipsOptions, ConceptRelationshipsResult, ConceptSuggestion, ConceptSummary, GetConceptByCodeOptions, GetConceptOptions, + RecommendedConceptEntry, RecommendedConceptsOptions, RecommendedConceptsResult, RelatedConcept, @@ -47,6 +49,21 @@ export type { ListDomainsOptions, } from './domains/interfaces/index.js'; export { OMOPHubError, OMOPHubIteratorError } from './errors.js'; +export { type FhirVersion, omophubFhirUrl } from './fhir/fhir-url.js'; +export type { + CodeableConcept, + Coding, + FhirBatchResult, + FhirBatchSummary, + FhirCodeableConceptResult, + FhirResolution, + FhirResolveResult, + RecommendedConceptOutput, + ResolveBatchOptions, + ResolveCodeableConceptOptions, + ResolvedConcept, + ResolveOptions, +} from './fhir/interfaces/index.js'; export type { Ancestor, AncestorsOptions, diff --git a/src/search/interfaces/bulk-search.ts b/src/search/interfaces/bulk-search.ts index a695cc5..65c76b5 100644 --- a/src/search/interfaces/bulk-search.ts +++ b/src/search/interfaces/bulk-search.ts @@ -36,12 +36,12 @@ export interface BulkBasicResultItem { duration?: number; } -export interface BulkBasicSearchResponse { - results: BulkBasicResultItem[]; - total_searches: number; - completed_searches: number; - failed_searches: number; -} +/** + * `POST /search/bulk` returns a **bare array** of per-search result + * items, NOT a summary wrapper. Use `data.length` for the count and + * `data.filter(r => r.status === 'completed').length` for completed. + */ +export type BulkBasicSearchResponse = BulkBasicResultItem[]; export interface BulkSemanticResultItem { search_id: string; @@ -52,12 +52,14 @@ export interface BulkSemanticResultItem { duration?: number; } +/** + * `POST /search/semantic-bulk` returns a wrapper object — unlike + * `bulkBasic` which is a bare array. The semantic endpoint adds aggregate + * counts and a total duration to the response. + */ export interface BulkSemanticSearchResponse { results: BulkSemanticResultItem[]; total_searches: number; - /** Note the naming difference vs `BulkBasicSearchResponse.completed_searches` - * — the server uses `completed_count`/`failed_count` for the semantic - * endpoint. Surfaced as-is to match the wire. */ completed_count: number; failed_count: number; total_duration?: number; diff --git a/src/search/interfaces/index.ts b/src/search/interfaces/index.ts index 9888559..2a563a4 100644 --- a/src/search/interfaces/index.ts +++ b/src/search/interfaces/index.ts @@ -15,6 +15,8 @@ export type { export type { BulkSemanticDefaults, BulkSemanticOptions } from './bulk-semantic-options.js'; export type { PaginateOptions } from './paginate-options.js'; export type { + AutocompleteEntry, + AutocompleteResult, SearchFacet, SearchFacets, SearchMetadata, diff --git a/src/search/interfaces/search-result.ts b/src/search/interfaces/search-result.ts index 31ed98d..43d61cc 100644 --- a/src/search/interfaces/search-result.ts +++ b/src/search/interfaces/search-result.ts @@ -33,3 +33,22 @@ export interface SearchResult { facets?: SearchFacets; search_metadata?: SearchMetadata; } + +/** + * One entry in `AutocompleteResult.suggestions`. The server nests the + * concept under a `suggestion` field and may add scoring fields alongside. + */ +export interface AutocompleteEntry { + suggestion: Concept; + match_score?: number; + match_type?: string; +} + +/** + * `GET /search/suggest` returns `{ query, suggestions: [...] }` — the + * caller's original query is echoed back. Wrapped, not a bare array. + */ +export interface AutocompleteResult { + query: string; + suggestions: AutocompleteEntry[]; +} diff --git a/src/search/search.ts b/src/search/search.ts index f63b749..046ab2e 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -8,7 +8,7 @@ import { import { type PaginateAllResult, paginate, paginateAll } from '../common/utils/paginate.js'; import { syntheticError } from '../common/utils/synthetic-error.js'; import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; -import type { Concept, ConceptSuggestion } from '../concepts/interfaces/concept.js'; +import type { Concept } from '../concepts/interfaces/concept.js'; import type { Response as OMOPHubResponse } from '../interfaces.js'; import type { AdvancedSearchOptions } from './interfaces/advanced-search-options.js'; import type { AutocompleteOptions } from './interfaces/autocomplete-options.js'; @@ -22,7 +22,7 @@ import type { } from './interfaces/bulk-search.js'; import type { BulkSemanticOptions } from './interfaces/bulk-semantic-options.js'; import type { PaginateOptions } from './interfaces/paginate-options.js'; -import type { SearchResult } from './interfaces/search-result.js'; +import type { AutocompleteResult, SearchResult } from './interfaces/search-result.js'; import type { SemanticSearchOptions } from './interfaces/semantic-search-options.js'; import type { SemanticSearchResult, @@ -150,14 +150,16 @@ export class Search { // ─── Autocomplete ────────────────────────────────────────────────── /** - * Lightweight typeahead suggestions. + * Lightweight typeahead suggestions. The server returns + * `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }` + * — `query` echoes the input, `suggestions` is the array. */ async autocomplete( query: string, options: AutocompleteOptions & GetOptions = {}, - ): Promise> { + ): Promise> { const { signal, headers, query: extraQuery, ...flags } = options; - return this.client.get('/search/suggest', { + return this.client.get('/search/suggest', { signal, headers, query: { ...flags, ...extraQuery, query }, diff --git a/src/vocabularies/interfaces/index.ts b/src/vocabularies/interfaces/index.ts index 603aaf2..2d942d0 100644 --- a/src/vocabularies/interfaces/index.ts +++ b/src/vocabularies/interfaces/index.ts @@ -1,7 +1,11 @@ export type { ListVocabulariesOptions } from './list-vocabularies-options.js'; export type { ConceptClass, + ListConceptClassesResult, + ListVocabulariesResult, + ListVocabularyDomainsResult, Vocabulary, + VocabularyConceptsResult, VocabularyDomain, VocabularyStats, VocabularySummary, diff --git a/src/vocabularies/interfaces/vocabulary.ts b/src/vocabularies/interfaces/vocabulary.ts index 81cec18..ca65fc1 100644 --- a/src/vocabularies/interfaces/vocabulary.ts +++ b/src/vocabularies/interfaces/vocabulary.ts @@ -46,3 +46,32 @@ export interface ConceptClass { concept_class_name: string; concept_class_concept_id?: number; } + +/** + * `GET /vocabularies` returns a named-wrapper object with the array under + * `vocabularies` (NOT a bare array, NOT a generic `data` envelope). + * Pagination metadata lives on the outer `Response.meta.pagination`. + * + * Items are typed as `VocabularySummary` (= `Vocabulary` + optional `stats`) + * so callers passing `includeStats: true` can access the populated `stats` + * field without casting. `stats` is undefined when `includeStats` is false. + */ +export interface ListVocabulariesResult { + vocabularies: VocabularySummary[]; +} + +/** + * `GET /vocabularies/{id}/concepts` returns a **bare array** of full + * `Concept` rows. Pagination metadata lives on the outer `Response.meta`. + */ +export type VocabularyConceptsResult = import('../../concepts/interfaces/concept.js').Concept[]; + +export interface ListVocabularyDomainsResult { + domains: VocabularyDomain[]; +} + +/** + * `GET /vocabularies/concept-classes` returns a **bare array** of + * concept-class rows. + */ +export type ListConceptClassesResult = ConceptClass[]; diff --git a/src/vocabularies/vocabularies.ts b/src/vocabularies/vocabularies.ts index 4a8eb7d..bc023e6 100644 --- a/src/vocabularies/vocabularies.ts +++ b/src/vocabularies/vocabularies.ts @@ -1,13 +1,13 @@ import type { OMOPHub } from '../client.js'; import type { GetOptions } from '../common/interfaces/get-options.js'; -import type { PaginatedData } from '../common/interfaces/pagination.js'; -import type { ConceptSummary } from '../concepts/interfaces/concept.js'; import type { Response as OMOPHubResponse } from '../interfaces.js'; import type { ListVocabulariesOptions } from './interfaces/list-vocabularies-options.js'; import type { - ConceptClass, + ListConceptClassesResult, + ListVocabulariesResult, + ListVocabularyDomainsResult, Vocabulary, - VocabularyDomain, + VocabularyConceptsResult, VocabularyStats, } from './interfaces/vocabulary.js'; import type { VocabularyConceptsOptions } from './interfaces/vocabulary-concepts-options.js'; @@ -16,15 +16,16 @@ export class Vocabularies { constructor(private readonly client: OMOPHub) {} /** - * List vocabularies. + * List vocabularies. Result is wrapped under `vocabularies`; pagination + * metadata lives on the outer `Response.meta.pagination`. * * @see https://docs.omophub.com/api-reference/vocabularies/list */ async list( options: ListVocabulariesOptions & GetOptions = {}, - ): Promise>> { + ): Promise> { const { signal, headers, query, ...flags } = options; - return this.client.get>('/vocabularies', { + return this.client.get('/vocabularies', { signal, headers, query: { ...flags, ...query }, @@ -71,29 +72,33 @@ export class Vocabularies { /** * Vocabulary-scoped domain catalog. Distinct from `client.domains.list()` * which hits `/domains` — this one returns domains as they appear in the - * vocabulary metadata table. + * vocabulary metadata table. Wrapped under `domains`. */ - async domains(options: GetOptions = {}): Promise> { - return this.client.get('/vocabularies/domains', options); + async domains(options: GetOptions = {}): Promise> { + return this.client.get('/vocabularies/domains', options); } /** - * Concept-class catalog across all vocabularies. + * Concept-class catalog across all vocabularies. Returns a **bare array** + * of `ConceptClass` rows — confirmed against the live API; not wrapped. */ - async conceptClasses(options: GetOptions = {}): Promise> { - return this.client.get('/vocabularies/concept-classes', options); + async conceptClasses( + options: GetOptions = {}, + ): Promise> { + return this.client.get('/vocabularies/concept-classes', options); } /** - * Paginated listing of concepts within a single vocabulary, with - * optional search and standard/invalid filters. + * Paginated listing of concepts within a single vocabulary. Returns a + * **bare array** of `Concept` rows — confirmed against the live API; + * pagination metadata on outer `Response.meta`. */ async concepts( vocabularyId: string, options: VocabularyConceptsOptions & GetOptions = {}, - ): Promise>> { + ): Promise> { const { signal, headers, query, ...flags } = options; - return this.client.get>( + return this.client.get( `/vocabularies/${encodeURIComponent(vocabularyId)}/concepts`, { signal, headers, query: { ...flags, ...query } }, ); diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts new file mode 100644 index 0000000..f5ef15e --- /dev/null +++ b/test/auth/auth.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { getApiKey, hasApiKey, setApiKey } from '../../src/auth/auth.js'; + +describe('auth helpers', () => { + let originalKey: string | undefined; + + beforeEach(() => { + originalKey = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + }); + + afterEach(() => { + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + else delete process.env.OMOPHUB_API_KEY; + }); + + test('getApiKey returns undefined when env is unset', () => { + expect(getApiKey()).toBeUndefined(); + }); + + test('getApiKey returns the env value when set', () => { + process.env.OMOPHUB_API_KEY = 'oh_test_value'; + expect(getApiKey()).toBe('oh_test_value'); + }); + + test('setApiKey writes to OMOPHUB_API_KEY', () => { + setApiKey('oh_set_value'); + expect(process.env.OMOPHUB_API_KEY).toBe('oh_set_value'); + expect(getApiKey()).toBe('oh_set_value'); + }); + + test('hasApiKey returns false when unset or empty', () => { + expect(hasApiKey()).toBe(false); + process.env.OMOPHUB_API_KEY = ''; + expect(hasApiKey()).toBe(false); + }); + + test('hasApiKey returns true when set to a non-empty string', () => { + process.env.OMOPHUB_API_KEY = 'oh_present'; + expect(hasApiKey()).toBe(true); + }); + + test('setApiKey rejects empty / whitespace-only keys (consistent with hasApiKey)', () => { + expect(() => setApiKey('')).toThrowError(/non-empty/); + expect(() => setApiKey(' ')).toThrowError(/non-empty/); + // env stays unset, no silent write + expect(process.env.OMOPHUB_API_KEY).toBeUndefined(); + }); +}); diff --git a/test/fhir/fhir-url.test.ts b/test/fhir/fhir-url.test.ts new file mode 100644 index 0000000..434bae3 --- /dev/null +++ b/test/fhir/fhir-url.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest'; +import { omophubFhirUrl } from '../../src/fhir/fhir-url.js'; + +describe('omophubFhirUrl', () => { + test('defaults to r4', () => { + expect(omophubFhirUrl()).toBe('https://fhir.omophub.com/fhir/r4'); + }); + + test('accepts r4b, r5, r6', () => { + expect(omophubFhirUrl('r4b')).toBe('https://fhir.omophub.com/fhir/r4b'); + expect(omophubFhirUrl('r5')).toBe('https://fhir.omophub.com/fhir/r5'); + expect(omophubFhirUrl('r6')).toBe('https://fhir.omophub.com/fhir/r6'); + }); +}); diff --git a/test/fhir/fhir.test.ts b/test/fhir/fhir.test.ts new file mode 100644 index 0000000..c1b9692 --- /dev/null +++ b/test/fhir/fhir.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { createMockFetch, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +const SNOMED_SYSTEM = 'http://snomed.info/sct'; +const DIABETES_CODE = '44054006'; + +const mockResolution = { + input: { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + resolution: { + source_concept: { + concept_id: 1, + concept_name: '', + vocabulary_id: 'SNOMED', + concept_code: DIABETES_CODE, + }, + standard_concept: { + concept_id: 201826, + concept_name: 'Type 2 diabetes mellitus', + vocabulary_id: 'SNOMED', + concept_code: DIABETES_CODE, + }, + mapping_type: 'direct', + target_table: 'condition_occurrence', + }, +}; + +describe('client.fhir.resolve', () => { + test('hits POST /fhir/resolve with flat-form coding fields', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + resourceType: 'Condition', + includeRecommendations: true, + recommendationsLimit: 3, + }); + const { init, url } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + resource_type: 'Condition', + include_recommendations: true, + recommendations_limit: 3, + }); + }); + + test('accepts nested coding-object form', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + coding: { + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes', + userSelected: true, + }, + resourceType: 'Condition', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body).toEqual({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes', + user_selected: true, + resource_type: 'Condition', + }); + }); + + test('flat fields override coding-object fields when both supplied', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Provide flat values for every overlapping field — flat must win + // across the board, not just the field the caller "happens to also pass". + await client.fhir.resolve({ + coding: { + system: 'old-system', + code: 'old-code', + display: 'old-display', + vocabularyId: 'OLD_VOCAB', + }, + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes (flat)', + vocabularyId: 'SNOMED', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.system).toBe(SNOMED_SYSTEM); + expect(body.code).toBe(DIABETES_CODE); + expect(body.display).toBe('Type 2 diabetes (flat)'); + expect(body.vocabulary_id).toBe('SNOMED'); + }); + + test('flat fields inherit from coding only when the flat value is absent', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + coding: { + system: SNOMED_SYSTEM, + code: 'coding-code', + display: 'inherited-from-coding', + }, + code: DIABETES_CODE, // only `code` provided flat + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.code).toBe(DIABETES_CODE); // flat wins + expect(body.system).toBe(SNOMED_SYSTEM); // inherited from coding + expect(body.display).toBe('inherited-from-coding'); // inherited from coding + }); + + test('rejects synthetically when neither flat code nor coding.code is provided', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ system: 'foo' }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects coding-object without a code', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ coding: { system: 'foo' } }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects when coding is mistakenly passed as an array (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // A caller passing `coding: [...]` (mistaking for FHIR `CodeableConcept.coding`) + // would otherwise slip past the object check since `typeof [] === 'object'`. + const { error } = await client.fhir.resolve({ + coding: [{ system: SNOMED_SYSTEM, code: DIABETES_CODE }] as unknown as Parameters< + typeof client.fhir.resolve + >[0]['coding'], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty string code (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ code: '' }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('passes onUnmapped through as snake_case', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + code: DIABETES_CODE, + onUnmapped: 'sentinel', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.on_unmapped).toBe('sentinel'); + }); +}); + +describe('client.fhir.resolveBatch', () => { + test('hits POST /fhir/resolve/batch with 1-100 codings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { results: [], summary: { total: 2, resolved: 2, failed: 0 } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolveBatch( + [ + { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition', includeQuality: true }, + ); + const { url, init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve/batch'); + const body = JSON.parse(init.body as string); + expect(body.codings).toHaveLength(2); + expect(body.resource_type).toBe('Condition'); + expect(body.include_quality).toBe(true); + }); + + test('rejects empty array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolveBatch([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects >100 codings synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 101 }, (_, i) => ({ + system: SNOMED_SYSTEM, + code: `${i}`, + })); + const { error } = await client.fhir.resolveBatch(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.fhir.resolveCodeableConcept', () => { + test('hits POST /fhir/resolve/codeable-concept with 1-20 codings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { input: {}, alternatives: [], unresolved: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolveCodeableConcept( + [ + { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9', userSelected: true }, + ], + { text: 'Type 2 diabetes', resourceType: 'Condition' }, + ); + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve/codeable-concept'); + const body = JSON.parse(init.body as string); + expect(body.coding).toHaveLength(2); + expect(body.coding[1].user_selected).toBe(true); + expect(body.text).toBe('Type 2 diabetes'); + expect(body.resource_type).toBe('Condition'); + }); + + test('rejects >20 codings synthetically (lower cap than resolveBatch)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 21 }, (_, i) => ({ + system: SNOMED_SYSTEM, + code: `${i}`, + })); + const { error } = await client.fhir.resolveCodeableConcept(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolveCodeableConcept([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..d35fd88 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["e2e/**/*.ts", "vitest.e2e.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..8fcd1ac --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vitest/config'; + +// Minimal .env loader — just enough to pick up OMOPHUB_API_KEY for the +// e2e suite. Keeping a runtime dotenv dependency out of the package. +try { + const env = readFileSync(new URL('.env', import.meta.url), 'utf8'); + for (const line of env.split('\n')) { + const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + if (!key) continue; + const value = (rawValue ?? '').replace(/^['"](.*)['"]$/, '$1'); + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} catch { + // .env is optional — tests skip themselves if OMOPHUB_API_KEY isn't set. +} + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['e2e/**/*.test.ts'], + // Live API can be slow — generous per-test timeout + testTimeout: 90_000, + hookTimeout: 90_000, + // E2E hits a shared API; run sequentially to avoid rate-limit hot-spotting + fileParallelism: false, + sequence: { concurrent: false }, + }, +}); From c7dde2b08f83a75f104f1a693643f3e940572559 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 08:29:37 +0100 Subject: [PATCH 07/16] Enhance environment variable loading in e2e tests with improved parsing and comment handling. Update softThrottle implementation for better API rate limiting across test files. Modify error handling in tests to account for rate limit exceeded scenarios. Introduce common options interface for FHIR resolver methods to reduce redundancy. Add tests for setApiKey to ensure proper error handling for read-only environment scenarios. --- e2e/_helpers.ts | 31 +++++++++++++---- e2e/concepts.test.ts | 4 +-- e2e/domains.test.ts | 2 +- e2e/server-errors.test.ts | 21 ++++++++++++ e2e/vocabularies.test.ts | 25 +++++--------- src/auth/auth.ts | 12 ++++++- src/fhir/interfaces/index.ts | 1 + src/fhir/interfaces/resolve-batch-options.ts | 16 ++++----- .../resolve-codeable-concept-options.ts | 9 ++--- src/fhir/interfaces/resolve-common-options.ts | 21 ++++++++++++ src/fhir/interfaces/resolve-options.ts | 22 ++++-------- src/index.ts | 1 + src/vocabularies/interfaces/vocabulary.ts | 12 ++++--- test/auth/auth.test.ts | 34 +++++++++++++++++++ vitest.e2e.config.ts | 31 ++++++++++++++--- 15 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 src/fhir/interfaces/resolve-common-options.ts diff --git a/e2e/_helpers.ts b/e2e/_helpers.ts index dbae52e..3adf546 100644 --- a/e2e/_helpers.ts +++ b/e2e/_helpers.ts @@ -55,13 +55,30 @@ export function e2eClientNoRetry(): OMOPHub { * Brief per-suite throttle to avoid hot-spotting the API rate limiter * when the full e2e suite runs back-to-back. ~2 req/sec global ceiling. * - * The default 200ms was too aggressive — running ~120 sequential tests - * triggered server-side rate limits even though each test made only one - * call. 500ms keeps the suite under any reasonable per-minute quota. + * Two implementation notes: + * + * 1. **Cross-file state via `globalThis`** — vitest loads each test file + * in its own module context, so a plain `let lastRequestAt = 0` would + * reset at every file boundary, letting the first call in each of the + * 11 e2e files fire with zero delay. We pin the timestamp on + * `globalThis` (a single shared object across module contexts) so the + * throttle is genuinely suite-wide. + * + * 2. **Reservation pattern** — each call advances `nextAllowedAt` to its + * target time *before* awaiting, so even concurrent calls (which the + * suite avoids today via `fileParallelism: false`, but might allow in + * the future) get queued slots ~500 ms apart rather than racing on a + * single shared timestamp. */ -let lastRequestAt = 0; +const THROTTLE_MS = 500; +const THROTTLE_KEY = Symbol.for('omophub-node:e2e:softThrottle:nextAllowedAt'); +type ThrottleHost = { [THROTTLE_KEY]?: number }; + export async function softThrottle(): Promise { - const elapsed = Date.now() - lastRequestAt; - if (elapsed < 500) await new Promise((r) => setTimeout(r, 500 - elapsed)); - lastRequestAt = Date.now(); + const host = globalThis as ThrottleHost; + const now = Date.now(); + const targetTime = Math.max(now, (host[THROTTLE_KEY] ?? 0) + THROTTLE_MS); + host[THROTTLE_KEY] = targetTime; + const wait = targetTime - now; + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); } diff --git a/e2e/concepts.test.ts b/e2e/concepts.test.ts index 3f0a739..457a91c 100644 --- a/e2e/concepts.test.ts +++ b/e2e/concepts.test.ts @@ -113,7 +113,7 @@ describe('e2e: client.concepts.getByCode', () => { includeRelationships: true, }); if (error) { - expect(['timeout_error', 'connection_error']).toContain(error.name); + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); return; } expect(data?.relationships).toBeTruthy(); @@ -200,7 +200,7 @@ describe('e2e: client.concepts.suggest', () => { vocabularyIds: ['SNOMED'], }); if (error) { - expect(['timeout_error', 'connection_error']).toContain(error.name); + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); return; } expect(data).toBeTruthy(); diff --git a/e2e/domains.test.ts b/e2e/domains.test.ts index e90b67f..d577c55 100644 --- a/e2e/domains.test.ts +++ b/e2e/domains.test.ts @@ -35,7 +35,7 @@ describe('e2e: client.domains.concepts', () => { standardOnly: true, }); if (error) { - expect(['timeout_error', 'connection_error']).toContain(error.name); + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); return; } expect(Array.isArray(data?.concepts)).toBe(true); diff --git a/e2e/server-errors.test.ts b/e2e/server-errors.test.ts index b24cb92..0b3e14b 100644 --- a/e2e/server-errors.test.ts +++ b/e2e/server-errors.test.ts @@ -3,11 +3,24 @@ import { e2eClientNoRetry, e2eEnabled, softThrottle } from './_helpers.js'; const runOrSkip = e2eEnabled ? test : test.skip; +/** + * Helper: the server-errors suite uses the no-retry client so each test + * fails fast on a known-bad call. That also means a transient 429 from + * the shared rate limiter shows up directly as `rate_limit_exceeded` + * instead of being retried away. We treat that as an inconclusive run + * for the specific error-contract being tested (not a failure of the + * SDK's contract under test). + */ +function isTransient(name?: string): boolean { + return name === 'rate_limit_exceeded' || name === 'timeout_error' || name === 'connection_error'; +} + describe('e2e: server-side errors', () => { runOrSkip('concepts.get with a non-existent ID returns not_found', async () => { await softThrottle(); const client = e2eClientNoRetry(); const { data, error } = await client.concepts.get(999_999_999); + if (isTransient(error?.name)) return; expect(data).toBeNull(); expect(error?.name).toBe('not_found'); expect(error?.statusCode).toBe(404); @@ -17,6 +30,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { data, error } = await client.concepts.getByCode('SNOMED', 'DEFINITELY-NOT-A-CODE'); + if (isTransient(error?.name)) return; expect(data).toBeNull(); expect(error?.name).toBe('not_found'); }); @@ -25,6 +39,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { data, error } = await client.vocabularies.get('THIS_VOCAB_DOES_NOT_EXIST'); + if (isTransient(error?.name)) return; expect(data).toBeNull(); expect(error).not.toBeNull(); expect([400, 404]).toContain(error?.statusCode); @@ -34,6 +49,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { data, error } = await client.domains.concepts('NotARealDomain'); + if (isTransient(error?.name)) return; // Server returns 200 OK with `{ concepts: [] }` for unknown domains // rather than a 404. Treat empty as the "no matches" signal. expect(error).toBeNull(); @@ -45,6 +61,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { error } = await client.hierarchy.ancestors(999_999_999); + if (isTransient(error?.name)) return; expect(error).not.toBeNull(); expect([400, 404]).toContain(error?.statusCode); }); @@ -53,6 +70,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { data, error } = await client.mappings.get(999_999_999); + if (isTransient(error?.name)) return; // Either: 404, OR a 200 with empty mappings array — both are acceptable if (error) { expect([400, 404]).toContain(error.statusCode); @@ -68,6 +86,7 @@ describe('e2e: server-side errors', () => { system: 'http://example.invalid/code-system', code: '12345', }); + if (isTransient(error?.name)) return; expect(data).toBeNull(); expect(error).not.toBeNull(); expect(typeof error?.name).toBe('string'); @@ -78,6 +97,7 @@ describe('e2e: server-side errors', () => { await softThrottle(); const client = e2eClientNoRetry(); const { error, headers } = await client.concepts.get(999_999_999); + if (isTransient(error?.name)) return; expect(error).not.toBeNull(); // requestId must surface on BOTH the error object AND the raw // headers, and the two must match — that's the support-triage contract. @@ -95,6 +115,7 @@ describe('e2e: server-side errors', () => { const { data, error } = await client.search.basic('zzz-no-such-concept-aaa-xyz', { pageSize: 5, }); + if (isTransient(error?.name)) return; expect(error).toBeNull(); expect(Array.isArray(data?.concepts)).toBe(true); // Empty matches is a successful query with zero results diff --git a/e2e/vocabularies.test.ts b/e2e/vocabularies.test.ts index 68ba2a6..a1ae8a4 100644 --- a/e2e/vocabularies.test.ts +++ b/e2e/vocabularies.test.ts @@ -35,30 +35,23 @@ describe('e2e: client.vocabularies', () => { } }); - runOrSkip('list() with includeStats embeds per-vocab statistics', async () => { + runOrSkip('list() accepts includeStats: true and rows carry audit timestamps', async () => { await softThrottle(); const client = e2eClient(); const { data, error } = await client.vocabularies.list({ pageSize: 5, includeStats: true, }); + // The live API currently ignores `includeStats` on the list endpoint + // (stats are populated via `vocabularies.stats(id)` instead). The test + // verifies (a) the flag is accepted without server-side rejection and + // (b) returned rows carry `created_at` / `updated_at` audit timestamps. expect(error).toBeNull(); - // `stats` is the includeStats-gated field on `VocabularySummary`; - // it must be populated on at least one returned vocab when the flag is set. - const someHaveStats = (data?.vocabularies ?? []).some( - (v) => v.stats !== undefined && typeof v.stats.total_concepts === 'number', + expect(Array.isArray(data?.vocabularies)).toBe(true); + const someHaveTimestamps = (data?.vocabularies ?? []).some( + (v) => typeof v.created_at === 'string' && typeof v.updated_at === 'string', ); - expect(someHaveStats).toBe(true); - }); - - runOrSkip('list() without includeStats omits the stats field', async () => { - await softThrottle(); - const client = e2eClient(); - const { data, error } = await client.vocabularies.list({ pageSize: 5 }); - expect(error).toBeNull(); - for (const v of data?.vocabularies ?? []) { - expect(v.stats).toBeUndefined(); - } + expect(someHaveTimestamps).toBe(true); }); runOrSkip('get("SNOMED") returns vocabulary metadata', async () => { diff --git a/src/auth/auth.ts b/src/auth/auth.ts index b5ae6ac..a0883e1 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -27,7 +27,17 @@ export function setApiKey(key: string): void { if (typeof key !== 'string' || key.trim().length === 0) { throw new OMOPHubError('setApiKey requires a non-empty key string.'); } - process.env[ENV_VAR] = key; + // Direct env assignment can throw `TypeError` in runtimes where + // process.env is frozen / sealed / read-only-proxied (some edge + // runtimes, restricted Bun modes, etc.). Convert to `OMOPHubError` so + // callers can rely on the documented exception type. + try { + process.env[ENV_VAR] = key; + } catch (err) { + throw new OMOPHubError( + `setApiKey could not write to process.env: ${err instanceof Error ? err.message : String(err)}`, + ); + } } /** diff --git a/src/fhir/interfaces/index.ts b/src/fhir/interfaces/index.ts index 0641017..db1ca73 100644 --- a/src/fhir/interfaces/index.ts +++ b/src/fhir/interfaces/index.ts @@ -11,4 +11,5 @@ export type { } from './fhir.js'; export type { ResolveBatchOptions } from './resolve-batch-options.js'; export type { ResolveCodeableConceptOptions } from './resolve-codeable-concept-options.js'; +export type { ResolveCommonOptions } from './resolve-common-options.js'; export type { ResolveOptions } from './resolve-options.js'; diff --git a/src/fhir/interfaces/resolve-batch-options.ts b/src/fhir/interfaces/resolve-batch-options.ts index 472ba86..61efb0f 100644 --- a/src/fhir/interfaces/resolve-batch-options.ts +++ b/src/fhir/interfaces/resolve-batch-options.ts @@ -1,8 +1,8 @@ -export interface ResolveBatchOptions { - resourceType?: string; - includeRecommendations?: boolean; - /** Max recommendations per coding (1–20). Default 5 at the API. */ - recommendationsLimit?: number; - includeQuality?: boolean; - onUnmapped?: 'error' | 'sentinel'; -} +import type { ResolveCommonOptions } from './resolve-common-options.js'; + +/** + * Options for `fhir.resolveBatch(codings, options)`. All fields are + * inherited from `ResolveCommonOptions` — `recommendationsLimit` is + * applied **per coding** in the batch. + */ +export type ResolveBatchOptions = ResolveCommonOptions; diff --git a/src/fhir/interfaces/resolve-codeable-concept-options.ts b/src/fhir/interfaces/resolve-codeable-concept-options.ts index 7487102..c1c676c 100644 --- a/src/fhir/interfaces/resolve-codeable-concept-options.ts +++ b/src/fhir/interfaces/resolve-codeable-concept-options.ts @@ -1,9 +1,6 @@ -export interface ResolveCodeableConceptOptions { +import type { ResolveCommonOptions } from './resolve-common-options.js'; + +export interface ResolveCodeableConceptOptions extends ResolveCommonOptions { /** Optional human-readable description of the CodeableConcept. */ text?: string; - resourceType?: string; - includeRecommendations?: boolean; - recommendationsLimit?: number; - includeQuality?: boolean; - onUnmapped?: 'error' | 'sentinel'; } diff --git a/src/fhir/interfaces/resolve-common-options.ts b/src/fhir/interfaces/resolve-common-options.ts new file mode 100644 index 0000000..0118173 --- /dev/null +++ b/src/fhir/interfaces/resolve-common-options.ts @@ -0,0 +1,21 @@ +/** + * Shared option fields accepted by every FHIR resolver method + * (`resolve`, `resolveBatch`, `resolveCodeableConcept`). Extracted so a + * new field added today reaches all three methods without manual + * replication. + */ +export interface ResolveCommonOptions { + /** FHIR resource type for context-aware mapping (e.g. `'Condition'`, `'Observation'`). */ + resourceType?: string; + /** Include Phoebe-style recommendations alongside the resolution. */ + includeRecommendations?: boolean; + /** Max recommendations (1–20). Default 5 at the API. */ + recommendationsLimit?: number; + /** Surface the server's quality bucket on the resolution. */ + includeQuality?: boolean; + /** + * `'error'` (default) returns a 404 for unmapped codings; `'sentinel'` + * returns a `concept_id: 0` row so batch flows can keep going. + */ + onUnmapped?: 'error' | 'sentinel'; +} diff --git a/src/fhir/interfaces/resolve-options.ts b/src/fhir/interfaces/resolve-options.ts index 0613a32..4889cf6 100644 --- a/src/fhir/interfaces/resolve-options.ts +++ b/src/fhir/interfaces/resolve-options.ts @@ -1,4 +1,5 @@ import type { Coding } from './fhir.js'; +import type { ResolveCommonOptions } from './resolve-common-options.js'; /** * Options for `fhir.resolve()`. Accepts either flat fields @@ -7,8 +8,13 @@ import type { Coding } from './fhir.js'; * precedence when both are supplied. * * At minimum, `code` (or `coding.code`) is required. + * + * Common knobs (`resourceType`, `includeRecommendations`, + * `recommendationsLimit`, `includeQuality`, `onUnmapped`) are inherited + * from `ResolveCommonOptions` and shared with `resolveBatch` / + * `resolveCodeableConcept`. */ -export interface ResolveOptions { +export interface ResolveOptions extends ResolveCommonOptions { // Flat-form fields system?: string; code?: string; @@ -17,18 +23,4 @@ export interface ResolveOptions { // Nested-form coding?: Coding; - - // Common - /** FHIR resource type for context-aware mapping (e.g. `'Condition'`, `'Observation'`). */ - resourceType?: string; - /** Include Phoebe-style recommendations alongside the resolution. */ - includeRecommendations?: boolean; - /** Max recommendations (1–20). Default 5 at the API. */ - recommendationsLimit?: number; - includeQuality?: boolean; - /** - * `'error'` (default) returns a 404 for unmapped codings; `'sentinel'` - * returns a `concept_id: 0` row so batch flows can keep going. - */ - onUnmapped?: 'error' | 'sentinel'; } diff --git a/src/index.ts b/src/index.ts index 3a70466..ff753ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,7 @@ export type { RecommendedConceptOutput, ResolveBatchOptions, ResolveCodeableConceptOptions, + ResolveCommonOptions, ResolvedConcept, ResolveOptions, } from './fhir/interfaces/index.js'; diff --git a/src/vocabularies/interfaces/vocabulary.ts b/src/vocabularies/interfaces/vocabulary.ts index ca65fc1..d651376 100644 --- a/src/vocabularies/interfaces/vocabulary.ts +++ b/src/vocabularies/interfaces/vocabulary.ts @@ -8,6 +8,10 @@ export interface Vocabulary { vocabulary_concept_id?: number; vocabulary_reference?: string; vocabulary_version?: string; + /** ISO timestamp the vocabulary row was added to the deployment. */ + created_at?: string; + /** ISO timestamp the vocabulary row was last refreshed. */ + updated_at?: string; } export interface VocabularyStats { @@ -52,12 +56,12 @@ export interface ConceptClass { * `vocabularies` (NOT a bare array, NOT a generic `data` envelope). * Pagination metadata lives on the outer `Response.meta.pagination`. * - * Items are typed as `VocabularySummary` (= `Vocabulary` + optional `stats`) - * so callers passing `includeStats: true` can access the populated `stats` - * field without casting. `stats` is undefined when `includeStats` is false. + * The `includeStats` query flag does NOT embed per-item stats here — + * the live API ignores it for the list endpoint. Use + * `client.vocabularies.stats(vocabularyId)` to fetch stats per vocabulary. */ export interface ListVocabulariesResult { - vocabularies: VocabularySummary[]; + vocabularies: Vocabulary[]; } /** diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts index f5ef15e..a6b7d46 100644 --- a/test/auth/auth.test.ts +++ b/test/auth/auth.test.ts @@ -46,4 +46,38 @@ describe('auth helpers', () => { // env stays unset, no silent write expect(process.env.OMOPHUB_API_KEY).toBeUndefined(); }); + + test('setApiKey re-throws read-only env errors as OMOPHubError', async () => { + const { OMOPHubError } = await import('../../src/errors.js'); + // Node's `process.env` is a Proxy that rejects accessor descriptors, + // so we wrap it in our own Proxy whose `set` trap throws — simulating + // a frozen / read-only env in an edge runtime — and swap it in + // temporarily. + const realEnv = process.env; + const readOnlyEnv = new Proxy( + {}, + { + get() { + return undefined; + }, + set() { + throw new TypeError('process.env is read-only here'); + }, + }, + ) as NodeJS.ProcessEnv; + Object.defineProperty(process, 'env', { + configurable: true, + get: () => readOnlyEnv, + }); + try { + expect(() => setApiKey('oh_test_value')).toThrowError(OMOPHubError); + expect(() => setApiKey('oh_test_value')).toThrowError(/could not write to process.env/); + } finally { + Object.defineProperty(process, 'env', { + configurable: true, + writable: true, + value: realEnv, + }); + } + }); }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 8fcd1ac..f9310eb 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -3,14 +3,37 @@ import { defineConfig } from 'vitest/config'; // Minimal .env loader — just enough to pick up OMOPHUB_API_KEY for the // e2e suite. Keeping a runtime dotenv dependency out of the package. +// +// Supported syntax (subset of dotenv): +// - KEY=value +// - KEY="value with spaces or # hash" (quoted: # is literal) +// - KEY='value' +// - KEY=value # inline comment (unquoted: trailing # stripped) +// - export KEY=value (export prefix allowed) +// - # full-line comments (ignored) try { const env = readFileSync(new URL('.env', import.meta.url), 'utf8'); - for (const line of env.split('\n')) { - const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + for (const rawLine of env.split('\n')) { + const line = rawLine.trimStart(); + if (line === '' || line.startsWith('#')) continue; + const match = line.match(/^(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/); if (!match) continue; - const [, key, rawValue] = match; + const [, key, rest] = match; if (!key) continue; - const value = (rawValue ?? '').replace(/^['"](.*)['"]$/, '$1'); + let value = (rest ?? '').trimEnd(); + // Quoted values: take the content between matching quotes literally + // (a `#` inside quotes is part of the value, not a comment). + const quoted = value.match(/^(['"])((?:\\.|(?!\1).)*)\1\s*(?:#.*)?$/); + if (quoted) { + value = quoted[2] ?? ''; + } else { + // Unquoted: strip a trailing inline comment introduced by ` #` + // (whitespace + hash). A `#` with no preceding whitespace is + // treated as part of the value (matches dotenv conventions). + const commentIdx = value.search(/\s+#/); + if (commentIdx !== -1) value = value.slice(0, commentIdx); + value = value.trim(); + } if (process.env[key] === undefined) { process.env[key] = value; } From 8f80afe3fdda0dbabae64ed8fd747708a16d7d04 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 12:01:26 +0100 Subject: [PATCH 08/16] Release version 1.0.0 of the OMOPHub Node.js SDK. Introduced comprehensive features including a new FHIR resolver, enhanced error handling, and improved API interactions. Updated documentation and examples for better user guidance. Updated dependencies and TypeScript configurations for improved performance and compatibility. --- CHANGELOG.md | 114 +- CONTRIBUTING.md | 230 +++ README.md | 578 +++++-- examples/README.md | 76 + examples/basic-usage.ts | 49 + examples/error-handling.ts | 179 ++ examples/fhir-resolver.ts | 288 ++++ examples/map-between-vocabularies.ts | 123 ++ examples/navigate-hierarchy.ts | 116 ++ examples/search-concepts.ts | 205 +++ package-lock.json | 2322 ++++++++------------------ package.json | 8 +- src/concepts/interfaces/concept.ts | 37 +- src/concepts/interfaces/index.ts | 1 + src/fhir/fhir.ts | 7 +- src/fhir/interfaces/fhir.ts | 10 +- src/index.ts | 1 + src/mappings/interfaces/mapping.ts | 23 +- tsconfig.examples.json | 9 + tsconfig.json | 1 + 20 files changed, 2445 insertions(+), 1932 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 examples/README.md create mode 100644 examples/basic-usage.ts create mode 100644 examples/error-handling.ts create mode 100644 examples/fhir-resolver.ts create mode 100644 examples/map-between-vocabularies.ts create mode 100644 examples/navigate-hierarchy.ts create mode 100644 examples/search-concepts.ts create mode 100644 tsconfig.examples.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a323c..61a747f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## 1.0.0 ### Added @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. The format - CI workflow on Node 22 and 24 (`build`, `lint`, `typecheck`, `test`). - npm publish workflow triggered by GitHub release, with provenance attestation. - HTTP layer: `get` / `post` / `patch` / `put` / `delete` methods on `OMOPHub` with retry on 429 + 502/503/504 + network errors. Full-jitter exponential backoff (500ms → 8s), `Retry-After` honoured up to 60s. -- Discriminated `Response = { data, error, meta, headers }` return type — errors never throw from network paths. +- Discriminated `Response = { data, error, meta, headers }` return type - errors never throw from network paths. - 16-code `OMOPHUB_ERROR_CODE_KEY` union with stable codes mapped from HTTP status + server-provided error codes. - `OMOPHubError` (thrown only on constructor misuse) and `OMOPHubIteratorError` (thrown from future async iterators). - Common request-option interfaces: `PerCallOptions`, `GetOptions`, `PostOptions` (with `idempotencyKey`), `PatchOptions`, `PutOptions`, `DeleteOptions`. @@ -26,106 +26,40 @@ All notable changes to this project will be documented in this file. The format - First resource: `client.vocabularies.list()` with snake_case query serialisation, pagination, and error mapping. - Test fixtures (`DIABETES_CONCEPT_ID` etc.) and mock-fetch helpers for `vi.fn`-based testing without external mock libraries. -### Changed - -- Retries are now gated by `isRetryableRequest(method, headers)`: idempotent verbs (GET/HEAD/OPTIONS/PUT/DELETE) always retry, but POST/PATCH only retry when an `Idempotency-Key` header is set. Prevents accidental duplicate writes on transient failures. -- Response body is drained via `response.body?.cancel()` before sleeping for a retry, releasing the undici connection back to the pool. -- `publishConfig.access` set to `public` so scoped-package publishes don't fall through to npm's default restricted access. -- **Resource method shape switched from "two options objects" to "positional path args + merged options"** — e.g. `client.concepts.get(201826, { includeRelationships: true })` rather than `client.concepts.get({ conceptId: 201826, includeRelationships: true })`. Matches Python/R ergonomics. `vocabularies.list` migrated to the new shape. - ### Added (resources) -- `client.concepts` — 7 methods: `get`, `getByCode`, `batch`, `suggest`, `related`, `relationships`, `recommended`. `concepts.get(0)` accepts the OMOP unmapped sentinel (R-SDK bug fix). `batch` validates 1–100 IDs synthetically; `recommended` validates `conceptIds` ≤ 100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. +- `client.concepts` - 7 methods: `get`, `getByCode`, `batch`, `suggest`, `related`, `relationships`, `recommended`. `concepts.get(0)` accepts the OMOP unmapped sentinel (R-SDK bug fix). `batch` validates 1–100 IDs synthetically; `recommended` validates `conceptIds` ≤ 100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. - `client.vocabularies` extended with 6 methods: `get`, `stats`, `domainStats`, `domains` (vocab-scoped), `conceptClasses`, `concepts`. -- `client.domains` — 2 methods: `list`, `concepts`. -- `client.search` — 11 methods: `basic`, `basicIter`, `basicAll`, `advanced`, `autocomplete`, `semantic`, `semanticIter`, `semanticAll`, `bulkBasic`, `bulkSemantic`, `similar`. `bulkBasic` validates 1–50 searches; `bulkSemantic` validates 1–25; `similar` enforces XOR of `conceptId`/`conceptName`/`query` both at the TS type level (discriminated union) and at runtime. -- `client.hierarchy` — 3 methods: `get` (flat or graph format), `ancestors`, `descendants`. Server caps `maxLevels` at 20. -- `client.relationships` — 2 methods: `get` (shares wire endpoint with `concepts.relationships` — kept as parallel discoverable surface), `types`. -- `client.mappings` — 2 methods: `get` and `map`. `map` enforces XOR of `sourceConcepts` vs `sourceCodes` at both type and runtime levels. `vocabRelease` is routed to the `?vocab_release=` query parameter rather than the JSON body (matches Python SDK convention). JSDoc documents the Procedure-domain vocabulary priority chain (SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → ICD9Proc → OPCS4 → OMOP Extension). -- `ConceptHierarchyNode` extended with `domain_id`, `concept_class_id`, `standard_concept` optional fields — now matches Python's `HierarchyConcept` and is re-exported as `HierarchyConcept`/`Ancestor`/`Descendant` from the hierarchy module. -- `ConceptRelationship` re-exported as `Relationship` from the relationships module — kept in sync via type alias. -- `client.fhir` — 3 methods: `resolve` (accepts both flat `{ system, code }` and nested `{ coding: {...} }` forms, mirroring the Python SDK's `_extract_coding`), `resolveBatch` (1–100 codings), `resolveCodeableConcept` (1–20 codings). All three validate synthetically before issuing requests. +- `client.domains` - 2 methods: `list`, `concepts`. +- `client.search` - 11 methods: `basic`, `basicIter`, `basicAll`, `advanced`, `autocomplete`, `semantic`, `semanticIter`, `semanticAll`, `bulkBasic`, `bulkSemantic`, `similar`. `bulkBasic` validates 1–50 searches; `bulkSemantic` validates 1–25; `similar` enforces XOR of `conceptId`/`conceptName`/`query` both at the TS type level (discriminated union) and at runtime. +- `client.hierarchy` - 3 methods: `get` (flat or graph format), `ancestors`, `descendants`. Server caps `maxLevels` at 20. +- `client.relationships` - 2 methods: `get` (shares wire endpoint with `concepts.relationships` - kept as parallel discoverable surface), `types`. +- `client.mappings` - 2 methods: `get` and `map`. `map` enforces XOR of `sourceConcepts` vs `sourceCodes` at both type and runtime levels. `vocabRelease` is routed to the `?vocab_release=` query parameter rather than the JSON body (matches Python SDK convention). JSDoc documents the Procedure-domain vocabulary priority chain (SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → ICD9Proc → OPCS4 → OMOP Extension). +- `ConceptHierarchyNode` extended with `domain_id`, `concept_class_id`, `standard_concept` optional fields - now matches Python's `HierarchyConcept` and is re-exported as `HierarchyConcept`/`Ancestor`/`Descendant` from the hierarchy module. +- `ConceptRelationship` re-exported as `Relationship` from the relationships module - kept in sync via type alias. +- `client.fhir` - 3 methods: `resolve` (accepts both flat `{ system, code }` and nested `{ coding: {...} }` forms, mirroring the Python SDK's `_extract_coding`), `resolveBatch` (1–100 codings), `resolveCodeableConcept` (1–20 codings). All three validate synthetically before issuing requests. - Standalone helpers (no client required): - - `omophubFhirUrl(version)` — returns the URL of OMOPHub's hosted FHIR Terminology Service (`'r4'` default, also `'r4b'`, `'r5'`, `'r6'`). - - `getApiKey()`, `setApiKey(key)`, `hasApiKey()` — env-backed helpers reading `OMOPHUB_API_KEY` from `process.env`. `setApiKey` throws `OMOPHubError` on edge runtimes that lack a writable `process.env`. -- FHIR `Coding` type uses camelCase (`userSelected`, `vocabularyId`) to match the FHIR JSON spec — converted to snake_case at the wire via `toSnakeCaseKeys`. + - `omophubFhirUrl(version)` - returns the URL of OMOPHub's hosted FHIR Terminology Service (`'r4'` default, also `'r4b'`, `'r5'`, `'r6'`). + - `getApiKey()`, `setApiKey(key)`, `hasApiKey()` - env-backed helpers reading `OMOPHUB_API_KEY` from `process.env`. `setApiKey` throws `OMOPHubError` on edge runtimes that lack a writable `process.env`. +- FHIR `Coding` type uses camelCase (`userSelected`, `vocabularyId`) to match the FHIR JSON spec - converted to snake_case at the wire via `toSnakeCaseKeys`. - README polish: install + config table + per-resource usage examples for all 8 resources + error-handling guide + async-iterator guide + Python/R migration table. -### SDK surface at v0.1.0 — 37 methods across 8 resources +### SDK surface at v1.0.0 - 37 methods across 8 resources ``` -client.concepts — get, getByCode, batch, suggest, related, relationships, recommended (7) -client.search — basic, basicIter, basicAll, advanced, autocomplete, semantic, +client.concepts - get, getByCode, batch, suggest, related, relationships, recommended (7) +client.search - basic, basicIter, basicAll, advanced, autocomplete, semantic, semanticIter, semanticAll, bulkBasic, bulkSemantic, similar (11) -client.vocabularies — list, get, stats, domainStats, domains, conceptClasses, concepts (7) -client.domains — list, concepts (2) -client.hierarchy — get, ancestors, descendants (3) -client.relationships — get, types (2) -client.mappings — get, map (2) -client.fhir — resolve, resolveBatch, resolveCodeableConcept (3) +client.vocabularies - list, get, stats, domainStats, domains, conceptClasses, concepts (7) +client.domains - list, concepts (2) +client.hierarchy - get, ancestors, descendants (3) +client.relationships - get, types (2) +client.mappings - get, map (2) +client.fhir - resolve, resolveBatch, resolveCodeableConcept (3) Σ = 37 Standalone: omophubFhirUrl, getApiKey, setApiKey, hasApiKey ``` -### Fixed (post-PR-6 e2e validation against live API — first pass) - -The initial 23-test e2e smoke suite uncovered response-shape drift between speculative types and the actual live API. Two of these (`vocabularies.concepts`, `vocabularies.conceptClasses`) were superseded by the comprehensive integration sweep below: - -- `vocabularies.list` → `ListVocabulariesResult = { vocabularies: Vocabulary[] }` (was union of bare array / generic `PaginatedData`). -- `vocabularies.concepts` → first typed as `{ concepts: [...] }`; **superseded** to bare `Concept[]` after the wider sweep. -- `vocabularies.domains` → `ListVocabularyDomainsResult = { domains: VocabularyDomain[] }`. -- `vocabularies.conceptClasses` → first typed as `{ concept_classes: [...] }`; **superseded** to bare `ConceptClass[]` after the wider sweep. -- `domains.list` → `ListDomainsResult = { domains: Domain[] }`. -- `domains.concepts` → `DomainConceptsResult = { concepts: ConceptSummary[] }`. -- `search.autocomplete` → `AutocompleteResult = { query: string, suggestions: AutocompleteEntry[] }`. Each entry nests the concept under `suggestion` and may carry `match_score` / `match_type`. - -**Pattern (refined after deeper integration testing):** the API uses named-wrapper objects for *most* collection responses but a handful of endpoints return bare arrays. See the "Confirmed wire patterns" section below for the authoritative list. Pagination metadata consistently lives on the outer envelope `Response.meta.pagination`, never nested inside `data`. - -### Added (testing — comprehensive integration sweep) - -- E2E test suite (`e2e/`) — **121 integration tests across 11 files** hitting `api.omophub.com/v1` live, gated by `OMOPHUB_API_KEY` (auto-skip when unset, never fail). Coverage: - - Per-resource happy-path + edge-case tests for all 8 resources. - - **`auth.test.ts`** — bad/empty API keys → live 401 mapping. - - **`server-errors.test.ts`** — 404/400/empty across resources. - - **`url-encoding.test.ts`** — slashes, spaces, ampersand, hash, Cyrillic, Japanese in queries. - - **`pagination.test.ts`** — `basicAll` maxPages cap, iterator early-break, has_next detection, partial-result accumulation. - - **`abort-timeout.test.ts`** — pre-aborted signal re-throws `AbortError`, mid-request abort, very-short `timeoutMs` returns `timeout_error`. - - **`hierarchy-relationships-mappings.test.ts`** — flat-vs-graph format, includeReverse parity, mapping vocabRelease query routing. - - **`fhir.test.ts`** — flat vs. nested coding forms, batch summary counts, includeQuality string, XOR validation. -- `vitest.e2e.config.ts` with sequential execution + 500 ms soft throttle + 90 s per-test timeout for live-API latency. -- Minimal `.env` loader (no `dotenv` runtime dep). -- `npm run test:e2e` script + `tsconfig.e2e.json`. -- `e2eClient()` (full retries + 45 s timeout) and `e2eClientNoRetry()` (no retries + 20 s timeout) helpers for happy-path vs. expected-error test paths. - -### Fixed (post-integration shape drift — 9 more wire-shape corrections) - -The expanded integration sweep uncovered additional response-shape mismatches the initial 23-test smoke suite hadn't exercised: - -- `vocabularies.concepts(vocabId)` → `VocabularyConceptsResult = Concept[]` (bare array, was speculatively typed as `{ concepts: [...] }`). -- `vocabularies.conceptClasses()` → `ListConceptClassesResult = ConceptClass[]` (bare array, was `{ concept_classes: [...] }`). -- `concepts.get(id, { includeRelationships })` → `Concept.relationships?: { parents?: ConceptRelationshipNode[]; children?: ConceptRelationshipNode[] }` — the include-flag form returns a compact `{ parents, children }` shape, **not** a flat `ConceptRelationship[]`. New `ConceptRelationshipNode` type added. -- `Concept` extended with `is_valid?`, `is_standard?`, `is_classification?` boolean convenience fields the server populates. -- `concepts.related(id)` → `RelatedConceptsResult = RelatedConcept[]` (bare array, was `{ related_concepts: [...] }`). `RelatedConcept` fields corrected: `relationship_id` / `relationship_name` / `relationship_score` / `relationship_distance` (was speculative `relatedness_score` / `relatedness_type`). -- `concepts.recommended({ conceptIds: [...] })` → `RecommendedConceptsResult = Record` — keyed by source concept ID **as a string**, not a flat `{ recommendations: [...] }`. Use `Object.entries(data)` to iterate. -- `search.bulkBasic` → `BulkBasicSearchResponse = BulkBasicResultItem[]` (bare array, was `{ results, total_searches, ... }` wrapper). -- `search.bulkSemantic` stays as a wrapper (`{ results, total_searches, completed_count, failed_count, total_duration? }`) — **different from `bulkBasic`**, the semantic endpoint adds aggregate counts. -- `FhirResolution.mapping_quality?: string` (bucket name like `'high'` / `'medium'` / `'low'` / `'manual_review'`, was speculatively typed as `Record`). -- Dropped speculative `ConceptRecommendation` interface (replaced by `RecommendedConceptEntry` keyed by source ID). - -### Confirmed wire patterns - -After the full integration sweep, the API's actual conventions: - -- **List endpoints are inconsistent**: most use named wrappers (`{ vocabularies }`, `{ domains }`, `{ relationship_types }`), but several return bare arrays (`vocabularies.concepts`, `vocabularies.conceptClasses`, `concepts.related`, `search.bulkBasic`). No way to predict from method name alone — the SDK types are now wire-accurate. -- **Single-resource endpoints** return the resource directly (`concepts.get`, `vocabularies.get`, etc.). -- **Pagination metadata** lives on the outer `Response.meta.pagination`, never inside `data`. -- **Server-side error handling varies**: unknown domains return `200 + { concepts: [] }`, unknown vocabularies return a structured 400/404, hierarchy validates `maxLevels` rather than silently capping. -- **Known slow paths**: `getByCode + includeRelationships`, `domains.concepts('Drug', { vocabularyIds: ['RxNorm'] })`, `hierarchy.get('flat')`, `search.basic(..., { exactMatch: true })` can take 25–60 s. The e2e suite tolerates these with the no-retry client + structured-timeout assertions. -- `basic` and `advanced` normalise three known server response shapes (`{ concepts, facets, search_metadata }`, legacy `{ data: [...] }`, bare `Concept[]`) into a stable `SearchResult` — addresses the cross-SDK shape drift documented in `project_search_api_response_shapes.md`. -- `similar` uses a two-arg `(options, requestOptions)` signature (not the merged style) because its `query` XOR field would otherwise collide with `PerCallOptions.query`. -- `paginate()` async generator and `paginateAll()` eager collector in `common/utils/` — generic page-walking helpers used by the search `*Iter` / `*All` variants. The async generator throws `OMOPHubIteratorError` on page failure; `paginateAll` accumulates errors as values. -- `syntheticError(name, message, details?)` helper in `common/utils/` — builds wire-shaped errors for client-side validation without issuing a network call. - - + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..18834a1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,230 @@ +# Contributing to OMOPHub Node.js SDK + +First off, thank you for considering contributing to OMOPHub! + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the [existing issues](https://github.com/OMOPHub/omophub-node/issues) to avoid duplicates. + +When creating a bug report, please include: + +- **Node version** (`node --version`) +- **Package manager + version** (`npm --version` / `pnpm --version` / `bun --version`) +- **SDK version** (`npm ls @omophub/omophub-node`) +- **Runtime** (Node, Deno, Bun, Cloudflare Workers, Vercel Edge, browser) +- **Operating system** +- **Minimal code example** that reproduces the issue +- **Full error output** - `error.name`, `error.statusCode`, `error.message`, and `error.requestId` if available +- **Expected vs actual behavior** + +If you're seeing a transport-level failure, include the response headers - especially `x-request-id` - so we can correlate against server logs. + +### Suggesting Features + +Feature requests are welcome! Please open an issue with: + +- Clear description of the feature +- Use case: why would this be useful? +- Proposed TypeScript surface (option keys, return shape) +- Possible implementation approach (optional) + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Install dependencies:** + ```bash + git clone https://github.com/YOUR_USERNAME/omophub-node.git + cd omophub-node + npm install + ``` +3. **Make your changes** with clear, descriptive commits +4. **Add tests** for new functionality - both unit tests (`test/`) and, when the change touches the wire, integration tests (`e2e/`) +5. **Run the full validation suite:** + ```bash + npm run typecheck + npm run lint + npm test + npm run build + ``` +6. **Run integration tests** if your change affects request/response shapes: + ```bash + OMOPHUB_API_KEY=oh_xxx npm run test:e2e + ``` +7. **Update documentation** - `README.md`, `CHANGELOG.md` under `## [Unreleased]`, and any relevant JSDoc +8. **Submit a pull request** with a clear description + +## Development Setup + +### Prerequisites + +- **Node.js 22+** (see `.nvmrc`) +- **npm 10+** (ships with Node 22) + +### Installation + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/omophub-node.git +cd omophub-node + +# Use the pinned Node version +nvm use # or `fnm use` + +# Install dependencies +npm install +``` + +For integration tests against the live API, create a `.env` file at the repo root: + +```bash +echo "export OMOPHUB_API_KEY=oh_xxx" > .env +``` + +`.env` is gitignored - never commit your key. + +### Running Tests + +```bash +# Unit tests (fast, fully mocked - no network) +npm test + +# Watch mode for active development +npm run test:watch + +# With coverage report +npm run test:coverage + +# Integration tests against the live API (requires OMOPHUB_API_KEY) +set -a && source .env && set +a +npm run test:e2e + +# Run a single test file +npx vitest run test/concepts/concepts.test.ts + +# Run tests matching a pattern +npx vitest run -t "bulk" +``` + +Unit tests live in `test/` and use `vitest` with `vi.fn()`-based mock fetches - no external mock libraries. Integration tests live in `e2e/` and exercise the SDK against `api.omophub.com/v1`; they tolerate rate limits and known slow paths via the helpers in `e2e/_helpers.ts`. + +### Code Style + +We use: + +- **Biome v2** for linting and formatting +- **TypeScript strict mode** (no `any`, no implicit returns, no unused locals) + +```bash +# Check lint + format +npm run lint + +# Auto-fix lint + format +npm run lint:fix + +# Format only +npm run format + +# Type-check (src + e2e) +npm run typecheck +``` + +Run `npm run lint:fix && npm run typecheck` before pushing. CI runs the same checks. + +### Running Examples + +The `examples/` directory contains runnable scripts you can use to smoke-test your changes end-to-end: + +```bash +set -a && source .env && set +a +npx tsx examples/basic-usage.ts +npx tsx examples/fhir-resolver.ts +``` + +If your change affects a public method, add or update an example. + +## Project Structure + +``` +omophub-node/ +├── src/ +│ ├── index.ts # Public API exports +│ ├── client.ts # OMOPHub client + HTTP dispatch + retry +│ ├── errors.ts # OMOPHubError, OMOPHubIteratorError, error codes +│ ├── version.ts # SDK version (auto-bumped) +│ ├── interfaces.ts # Shared request/response/option types +│ ├── auth/ # API key env helpers +│ ├── common/ # syntheticError, pagination, toSnakeCaseKeys, … +│ ├── concepts/ +│ │ ├── concepts.ts # Concepts resource methods +│ │ └── interfaces/ # Per-method option + response types +│ ├── search/ +│ ├── hierarchy/ +│ ├── relationships/ +│ ├── mappings/ +│ ├── vocabularies/ +│ ├── domains/ +│ └── fhir/ +├── test/ # Unit tests (mock-fetch, no network) +├── e2e/ # Integration tests (live api.omophub.com) +├── examples/ # Runnable example scripts +├── package.json +├── tsconfig.json # src build config +├── tsconfig.e2e.json # e2e typecheck config +├── tsconfig.examples.json # examples typecheck config +├── biome.json # Lint + format rules +├── vitest.config.ts # Unit test runner +└── vitest.e2e.config.ts # Integration test runner (fileParallelism: false) +``` + +## SDK Conventions + +A few patterns are load-bearing - please follow them when adding new methods: + +- **Discriminated returns, not exceptions.** Every method returns `Promise<{ data, error, meta, headers }>`. The only exceptions the SDK throws are `OMOPHubError` (constructor misuse) and `OMOPHubIteratorError` (`*Iter` async-generator page failures). Network errors, 404s, 429s, validation failures - all returned as `error` values. +- **camelCase TypeScript surface, snake_case wire.** Request options take camelCase keys (`vocabularyIds`, `pageSize`); they're converted to snake_case at the request boundary via `toSnakeCaseKeys`. Response types use snake_case to match the wire - never client-translated. +- **Synthetic validation when feasible.** Reject obviously-bad inputs (empty arrays, out-of-range counts, missing required fields) with `syntheticError(...)` before making a network call. Match the wire-shape of a real server error so callers' error-handling code doesn't have to branch. +- **Positional path args + merged options.** Prefer `client.concepts.get(201826, { includeRelationships: true })` over `({ conceptId: 201826, ... })`. Path params are positional; everything else goes in a single options object. +- **Idempotency-key retries.** GET/HEAD/OPTIONS/PUT/DELETE retry automatically on transient failures. POST/PATCH only retry when the caller sets `{ idempotencyKey: '...' }` - never silently retry a non-idempotent write. +- **Pagination metadata on the outer envelope.** `meta.pagination` lives on the response, never inside `data`. Add `*Iter` (async generator) and `*All` (eager collector with error accumulation) variants for any new paginated resource. + +When the live API returns a shape that doesn't match the existing types, **fix the type to match the wire** - don't transform on the response path. Log the discovery in `CHANGELOG.md` so the wire-drift history stays traceable. + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `test:` Adding or updating tests +- `refactor:` Code refactoring +- `chore:` Maintenance tasks + +Examples: +``` +feat: add semantic search endpoint +fix: handle rate limit errors correctly +docs: update README with new examples +test: add integration tests for batch concept lookup +``` + +## Release Process + +Releases are cut by maintainers via GitHub Releases - **do not bump the version in `package.json` in your PR**. The publish workflow (`.github/workflows/publish.yml`) runs on release-published, publishes to npm with provenance attestation, and tags the registry version. + +For each PR, add a bullet under `## [Unreleased]` in `CHANGELOG.md` describing the user-visible change. + +## Questions? + +- Open a [GitHub Discussion](https://github.com/OMOPHub/omophub-node/discussions) +- Email: support@omophub.com + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +Thank you for helping make OMOPHub better! diff --git a/README.md b/README.md index b096d8d..a558bbb 100644 --- a/README.md +++ b/README.md @@ -1,290 +1,504 @@ -# @omophub/omophub-node +# OMOPHub Node.js SDK -Official Node.js / TypeScript SDK for [OMOPHub](https://omophub.com) — search, lookup, map, and navigate OHDSI medical vocabularies (SNOMED, ICD10, RxNorm, LOINC, and 100+ more) from JavaScript. API reference at [docs.omophub.com](https://docs.omophub.com). +**Query millions of standardized medical concepts from TypeScript with full type safety** -## Install +Access SNOMED CT, ICD-10, RxNorm, LOINC, and 100+ OHDSI ATHENA vocabularies without downloading, installing, or maintaining local databases. + +[![npm version](https://img.shields.io/npm/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) +[![Node Version](https://img.shields.io/node/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) +[![Codecov](https://codecov.io/gh/OMOPHub/omophub-node/branch/main/graph/badge.svg)](https://app.codecov.io/gh/OMOPHub/omophub-node?branch=main) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +![Downloads](https://img.shields.io/npm/dm/@omophub/omophub-node) + +**[Documentation](https://docs.omophub.com/sdks/node/overview)** · +**[API Reference](https://docs.omophub.com/api-reference)** · +**[Examples](https://github.com/OMOPHub/omophub-node/tree/main/examples)** + +--- + +## Why OMOPHub? + +Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-gigabyte files, setting up a database instance, and writing complex SQL queries. **OMOPHub eliminates this friction.** + +| Traditional Approach | With OMOPHub | +|---------------------|--------------| +| Download 5GB+ ATHENA vocabulary files | `npm install @omophub/omophub-node` | +| Set up and maintain database | One API call | +| Write complex SQL with multiple JOINs | Simple TypeScript methods | +| Manually update vocabularies quarterly | Always current data | +| Local infrastructure required | Runs in Node, Deno, Bun, or any edge runtime | + +## Installation ```bash npm install @omophub/omophub-node +# or pnpm add / yarn add / bun add @omophub/omophub-node ``` -Requires Node ≥ 22. Runs in Node, modern browsers (CORS permitting), and edge runtimes (Cloudflare Workers, Vercel Edge). +Requires **Node ≥ 22**. Also runs in Deno, Bun, Cloudflare Workers, Vercel Edge, and modern browsers (CORS permitting). Pure ESM, zero runtime dependencies. -## Quick start +## Quick Start ```ts import { OMOPHub } from '@omophub/omophub-node'; -const client = new OMOPHub(process.env.OMOPHUB_API_KEY); +// Initialize client (uses OMOPHUB_API_KEY env var, or pass apiKey explicitly) +const client = new OMOPHub(); +// Get a concept by ID const { data, error } = await client.concepts.get(201826); if (error) throw new Error(error.message); -console.log(data.concept_name); // → "Type 2 diabetes mellitus" +console.log(data.concept_name); // "Type 2 diabetes mellitus" + +// Search for concepts across vocabularies +const search = await client.search.basic('metformin', { + vocabularyIds: ['RxNorm'], + domainIds: ['Drug'], +}); +for (const c of search.data?.concepts ?? []) { + console.log(`${c.concept_id}: ${c.concept_name}`); +} + +// Map ICD-10 code to SNOMED +const mapping = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [{ vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }], +}); + +// Navigate concept hierarchy +const ancestors = await client.hierarchy.ancestors(201826, { maxLevels: 3 }); ``` -Every method returns a discriminated `{ data, error, meta, headers }` — errors are values, not exceptions. Narrow with `if (error) ...` and TypeScript will type `data` correctly in the success branch. +**Errors are values, not exceptions.** Every method returns a discriminated `{ data, error, meta, headers }` union - narrow with `if (error) ...` and TypeScript types `data` correctly in the success branch. No try/catch boilerplate, no surprise throws on 404s or rate limits. -## Configuration +## FHIR-to-OMOP Resolution -| Option | Env var | Default | -|---|---|---| -| `apiKey` (1st constructor arg) | `OMOPHUB_API_KEY` | — (required) | -| `baseUrl` | `OMOPHUB_API_URL` | `https://api.omophub.com/v1` | -| `timeoutMs` | — | `30000` (set to `0` to disable) | -| `maxRetries` | — | `3` (set to `0` to disable) | -| `vocabVersion` | — | unset (server picks latest) | -| `userAgent` | — | `omophub-node/` | -| `fetch` | — | `globalThis.fetch` | +Resolve FHIR coded values to OMOP standard concepts in one call: + +```ts +// Single FHIR Coding → OMOP concept + CDM target table +const { data } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', +}); +console.log(data.resolution.target_table); // "condition_occurrence" +console.log(data.resolution.mapping_type); // "direct" + +// ICD-10-CM → traverses "Maps to" automatically +const icd = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', +}); +console.log(icd.data?.resolution.standard_concept.vocabulary_id); // "SNOMED" + +// Batch resolve up to 100 codings +const batch = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, + { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '197696' }, + ], +); +console.log(`Resolved ${batch.data?.summary?.resolved}/${batch.data?.summary?.total}`); + +// CodeableConcept with vocabulary preference (SNOMED wins over ICD-10) +const codeable = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, +); +console.log(codeable.data?.best_match?.resolution.source_concept.vocabulary_id); // "SNOMED" +``` + +The resolver follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of a 404. ```ts -const client = new OMOPHub('oh_...', { - baseUrl: 'https://staging.api.omophub.com/v1', - timeoutMs: 60_000, - maxRetries: 5, - vocabVersion: '2025.1', +// Administrative gender → person.gender_concept_id (via IG ConceptMap) +await client.fhir.resolve({ + system: 'http://hl7.org/fhir/administrative-gender', + code: 'male', +}); + +// A user-selected coding wins over vocabulary preference +await client.fhir.resolveCodeableConcept([ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9', userSelected: true }, +]); + +// onUnmapped: 'sentinel' → concept_id 0 record instead of a 404 (one row per input for ETL) +await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '00000000', + onUnmapped: 'sentinel', }); + +// Display-only semantic fallback - no code/system needed +await client.fhir.resolve({ display: 'blood glucose measurement' }); ``` -## Resources +Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution.value_as_concept` (the IG Value-as-Concept pattern). `onUnmapped` is accepted by `resolve()`, `resolveBatch()`, and `resolveCodeableConcept()`. -The client exposes 8 resources covering the full OMOPHub API surface. +### FHIR Type Interoperability -### Concepts +The resolver accepts any Coding-like input via TypeScript structural typing - a plain object, the SDK's `Coding` interface, or any object with `system` and `code` properties (e.g. `fhir-kit-client` codings, generated FHIR resource types). ```ts -// Single concept by OMOP ID -await client.concepts.get(201826, { includeRelationships: true }); +import type { Coding } from '@omophub/omophub-node'; -// By native vocabulary code -await client.concepts.getByCode('SNOMED', '44054006'); +// SDK's typed interface - IDE autocomplete, no extra deps +const coding: Coding = { system: 'http://snomed.info/sct', code: '44054006' }; +const { data } = await client.fhir.resolve({ coding }); -// Up to 100 concepts in one call -await client.concepts.batch({ conceptIds: [201826, 1112807] }); +// Mixed shapes in a single batch call +await client.fhir.resolveBatch([ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, +]); +``` -// Type-ahead concept suggestions -await client.concepts.suggest('diab', { pageSize: 5 }); +### FHIR Server URL Helper -// Phoebe-style related concepts -await client.concepts.related(201826, { minScore: 0.5 }); +Point external FHIR client libraries at OMOPHub's hosted FHIR Terminology Service directly - useful when you need raw FHIR `Parameters` / `Bundle` responses instead of the Concept Resolver envelope. -// Relationship list for a concept -await client.concepts.relationships(201826); +```ts +import { omophubFhirUrl } from '@omophub/omophub-node'; -// OHDSI Phoebe recommendations -await client.concepts.recommended({ conceptIds: [201826], standardOnly: true }); +omophubFhirUrl(); // → "https://fhir.omophub.com/fhir/r4" +omophubFhirUrl('r5'); // → "https://fhir.omophub.com/fhir/r5" +omophubFhirUrl('r4b'); // → "https://fhir.omophub.com/fhir/r4b" ``` -### Search +**When to use which**: the Concept Resolver (`client.fhir.resolve`) gives you OMOP-enriched answers - standard concept ID, CDM target table, mapping quality. Use the FHIR server URL directly with a FHIR client when you need raw FHIR responses for FHIR-native tooling. + +## Semantic Search + +Use natural language queries to find concepts using neural embeddings: ```ts -// Keyword search — normalises both modern and legacy response shapes -const { data } = await client.search.basic('diabetes', { +// Natural language search - understands clinical intent +const { data } = await client.search.semantic('high blood sugar levels'); +for (const r of data?.results ?? []) { + console.log(`${r.concept_name} (similarity: ${r.similarity_score.toFixed(2)})`); +} + +// Filter by vocabulary and set minimum similarity threshold +await client.search.semantic('heart attack', { vocabularyIds: ['SNOMED'], - standardConcept: 'S', - pageSize: 50, + domainIds: ['Condition'], + threshold: 0.5, }); -console.log(data.concepts.length); -// Async iterator — walk every page -for await (const c of client.search.basicIter('diabetes', { pageSize: 100 })) { - console.log(c.concept_id); +// Iterate through all results with auto-pagination +for await (const r of client.search.semanticIter('chronic kidney disease', { pageSize: 50 })) { + console.log(`${r.concept_id}: ${r.concept_name}`); } +``` -// Eager collect across pages -const { data: allConcepts, errors } = await client.search.basicAll('diabetes', { - maxPages: 5, -}); - -// Advanced (POST) — relationship filters -await client.search.advanced('diabetes', { - relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: 4034964 }], -}); +### Bulk Search -// Semantic (embedding-based) search -await client.search.semantic('high blood sugar', { threshold: 0.85 }); +Search for multiple terms in a single API call - much faster than individual requests: -// Bulk: 50 basic / 25 semantic searches per call -await client.search.bulkBasic([ - { search_id: 'q1', query: 'diabetes' }, - { search_id: 'q2', query: 'hypertension' }, -]); +```ts +// Bulk lexical search (up to 50 queries) - `bulkBasic` returns a bare array +const bulk = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes mellitus' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, +); +for (const item of bulk.data ?? []) { + console.log(`${item.search_id}: ${item.results.length} results`); +} -// Similarity search (exactly one of conceptId / conceptName / query) -await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); +// Bulk semantic search (up to 25 queries) - returns a wrapper with aggregate counts +await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart failure treatment options' }, + { search_id: 's2', query: 'type 2 diabetes medication' }, + ], + { defaults: { threshold: 0.5, page_size: 10 } }, +); ``` -### Vocabularies +### Similarity Search + +Find concepts similar to a known concept or natural language query: ```ts -await client.vocabularies.list({ includeStats: true }); -await client.vocabularies.get('SNOMED'); -await client.vocabularies.stats('SNOMED'); -await client.vocabularies.domainStats('SNOMED', 'Condition'); -await client.vocabularies.domains(); // vocab-scoped -await client.vocabularies.conceptClasses(); -await client.vocabularies.concepts('SNOMED', { search: 'diabetes', pageSize: 100 }); +// Find concepts similar to a known concept +const sim = await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); +for (const r of sim.data?.similar_concepts ?? []) { + console.log(`${r.concept_name} (score: ${r.similarity_score.toFixed(2)})`); +} + +// Find similar concepts using a natural language query +await client.search.similar({ + query: 'medications for high blood pressure', + algorithm: 'semantic', + similarityThreshold: 0.6, + vocabularyIds: ['RxNorm'], + includeScores: true, +}); ``` -### Domains +## Async Iteration & Eager Collection + +Every paginated resource exposes `*Iter` (lazy async generator) and `*All` (eager collector) variants: ```ts -await client.domains.list(); // ~20-row catalog -await client.domains.concepts('Condition', { pageSize: 100 }); +// Async iterator - walks every page, throws OMOPHubIteratorError on page failure +for await (const c of client.search.basicIter('diabetes', { pageSize: 100 })) { + await process(c); +} + +// Eager collect - accumulates errors as values instead of throwing +const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + maxPages: 10, +}); +console.log(`Got ${data.length} concepts across ${pagesFetched} pages, ${errors.length} errors`); ``` -### Hierarchy +## Use Cases + +### ETL & Data Pipelines + +Validate and map clinical codes during OMOP CDM transformations: ```ts -await client.hierarchy.get(201826, { format: 'graph', maxLevels: 5 }); -await client.hierarchy.ancestors(201826, { vocabularyIds: ['SNOMED'] }); -await client.hierarchy.descendants(201826, { maxLevels: 3, domainIds: ['Condition'] }); +async function validateAndMap(sourceVocab: string, sourceCode: string): Promise { + const lookup = await client.concepts.getByCode(sourceVocab, sourceCode); + if (lookup.error || !lookup.data) return null; + + if (lookup.data.standard_concept === 'S') { + return lookup.data.concept_id; + } + + const mappings = await client.mappings.get(lookup.data.concept_id, { + targetVocabulary: 'SNOMED', + }); + return mappings.data?.mappings[0]?.target_concept_id ?? null; +} ``` -### Relationships +### Data Quality Checks + +Verify codes exist and are valid standard concepts: ```ts -await client.relationships.get(201826, { standardOnly: true }); -await client.relationships.types(); +const conditionCodes = ['E11.9', 'I10', 'J44.9']; // ICD-10 codes +for (const code of conditionCodes) { + const { data, error } = await client.concepts.getByCode('ICD10CM', code); + if (error?.name === 'not_found') { + console.log(`✗ ${code}: Invalid code!`); + } else if (data) { + console.log(`✓ ${code}: ${data.concept_name}`); + } +} ``` -### Mappings +### Phenotype Development + +Explore hierarchies to build comprehensive concept sets: ```ts -await client.mappings.get(201826, { targetVocabulary: 'ICD10CM' }); +// Get all descendants of "Type 2 diabetes mellitus" for phenotype +const { data } = await client.hierarchy.descendants(201826, { maxLevels: 5 }); +const conceptSet = data?.descendants.map((d) => d.concept_id) ?? []; +console.log(`Found ${conceptSet.length} concepts for T2DM phenotype`); +``` -// Map by OMOP concept IDs -await client.mappings.map({ - targetVocabulary: 'SNOMED', - sourceConcepts: [201826, 1112807], -}); +### Clinical Applications -// Map by native vocabulary codes -await client.mappings.map({ - targetVocabulary: 'SNOMED', - sourceCodes: [ - { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, - { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, - ], -}); +Build terminology lookups into healthcare applications - works in Next.js server components, Express, Fastify, Hono, and edge functions: + +```ts +// Autocomplete endpoint (Next.js route handler) +export async function GET(req: Request) { + const url = new URL(req.url); + const q = url.searchParams.get('q') ?? ''; + + const { data } = await client.search.autocomplete(q, { + vocabularyIds: ['SNOMED'], + pageSize: 10, + }); + return Response.json(data?.suggestions ?? []); +} ``` -### FHIR +## API Resources -```ts -// Resolve a single FHIR coding to its OMOP standard concept -const { data } = await client.fhir.resolve({ - system: 'http://snomed.info/sct', - code: '44054006', - resourceType: 'Condition', -}); -console.log(data.resolution.standard_concept.concept_name); -console.log(data.resolution.target_table); // → "condition_occurrence" +| Resource | Description | Key Methods | +|----------|-------------|-------------| +| `concepts` | Concept lookup, batch ops, suggestions | `get()`, `getByCode()`, `batch()`, `suggest()`, `related()`, `relationships()`, `recommended()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulkBasic()`, `bulkSemantic()`, plus `*Iter` / `*All` | +| `hierarchy` | Navigate concept relationships | `get()`, `ancestors()`, `descendants()` | +| `relationships` | Concept relationship lookup | `get()`, `types()` | +| `mappings` | Cross-vocabulary mappings | `get()`, `map()` | +| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()`, `domainStats()`, `domains()`, `conceptClasses()`, `concepts()` | +| `domains` | Domain catalog | `list()`, `concepts()` | +| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolveBatch()`, `resolveCodeableConcept()` | -// Up to 100 codings at once -await client.fhir.resolveBatch( - [ - { system: 'http://snomed.info/sct', code: '44054006' }, - { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, - ], - { resourceType: 'Condition', includeQuality: true }, -); +## Configuration -// CodeableConcept resolution (up to 20 codings, picks best match) -await client.fhir.resolveCodeableConcept( - [ - { system: 'http://snomed.info/sct', code: '44054006', userSelected: true }, - { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, - ], - { text: 'Type 2 diabetes mellitus' }, -); +```ts +const client = new OMOPHub('oh_xxx', { + baseUrl: 'https://api.omophub.com/v1', // API endpoint + timeoutMs: 30_000, // Request timeout (0 disables) + maxRetries: 3, // Retry attempts (0 disables) + vocabVersion: '2025.2', // Pin a specific vocabulary release + userAgent: 'my-app/1.0', // Override default UA + fetch: customFetch, // BYO fetch (e.g. for testing / proxies) +}); ``` -## Error handling +| Option | Env var | Default | +|---|---|---| +| `apiKey` (1st constructor arg) | `OMOPHUB_API_KEY` | - (required) | +| `baseUrl` | `OMOPHUB_API_URL` | `https://api.omophub.com/v1` | +| `timeoutMs` | - | `30000` | +| `maxRetries` | - | `3` | +| `vocabVersion` | - | unset (server picks latest) | -Every method returns a discriminated union — never throws on network or API errors. +The client retries `429`, `502`, `503`, `504`, and transient network errors automatically (jittered exponential backoff, `Retry-After` honoured up to 60s). **POST and PATCH only retry when an `Idempotency-Key` header is set** - pass it via `{ idempotencyKey: '...' }`. + +## Error Handling ```ts const { data, error, headers } = await client.concepts.get(999_999_999); if (error) { - console.error(error.name, error.message, error.statusCode); - if (error.name === 'rate_limit_exceeded') { - console.log('Retry after', error.retryAfter, 'seconds'); + switch (error.name) { + case 'not_found': + console.log(`Concept not found: ${error.message}`); + break; + case 'invalid_api_key': + case 'missing_api_key': + case 'restricted_api_key': + console.log('Check your API key'); + break; + case 'rate_limit_exceeded': + case 'tier_limit_exceeded': + console.log(`Rate limited. Retry after ${error.retryAfter}s`); + break; + case 'validation_error': + case 'missing_required_field': + case 'invalid_argument': + console.log(`Bad request: ${error.message}`); + break; + case 'timeout_error': + case 'connection_error': + console.log('Transport error - try again'); + break; + default: + console.log(`API error ${error.statusCode}: ${error.message}`); } return; } -console.log(data.concept_name); // narrowed +console.log(data.concept_name); // TypeScript narrows to the success type ``` -Error codes (`error.name`): - -| Code | Cause | -|---|---| -| `invalid_api_key`, `restricted_api_key`, `missing_api_key` | 401 / 403 | -| `not_found` | 404 | -| `validation_error`, `missing_required_field`, `invalid_argument` | 400 | -| `method_not_allowed`, `conflict` | 405 / 409 | -| `rate_limit_exceeded`, `tier_limit_exceeded` | 429 | -| `service_unavailable`, `internal_server_error` | 5xx | -| `connection_error`, `timeout_error` | Transport | -| `application_error` | Catch-all | - -The client retries `429`, `502`, `503`, `504`, and transient network errors automatically (jittered exponential backoff, `Retry-After` honoured up to 60s). **POST and PATCH only retry when an `Idempotency-Key` header is set** — pass it via `{ idempotencyKey: '...' }`. - -## Async iterators - -`*Iter` variants are async generators — they throw `OMOPHubIteratorError` if a page fails, since generators can't yield discriminated errors gracefully. `*All` variants accumulate errors into the return value: +Async iterators are the **only** API surface that throws - page failures during `for await` raise `OMOPHubIteratorError` (since generators can't yield discriminated errors gracefully): ```ts import { OMOPHubIteratorError } from '@omophub/omophub-node'; try { for await (const c of client.search.basicIter('diabetes')) { - console.log(c.concept_id); + /* ... */ } } catch (e) { if (e instanceof OMOPHubIteratorError) { - console.error(e.code, e.statusCode); + console.error(e.code, e.statusCode, e.message); } } - -// Or the value-based variant: -const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { - maxPages: 10, -}); ``` -## Standalone helpers +Prefer the `*All` variants (`basicAll`, `semanticAll`, etc.) when you want errors as values instead. + +## Type Safety + +The SDK ships hand-written TypeScript types for every request option and response shape - full IDE autocomplete, no codegen, no `any`. ```ts -import { - omophubFhirUrl, - getApiKey, - setApiKey, - hasApiKey, -} from '@omophub/omophub-node'; - -// Pointer to OMOPHub's hosted FHIR Terminology Service -omophubFhirUrl(); // → "https://fhir.omophub.com/fhir/r4" -omophubFhirUrl('r5'); // → "https://fhir.omophub.com/fhir/r5" - -// Env-backed key helpers -getApiKey(); // process.env.OMOPHUB_API_KEY -setApiKey('oh_...'); // writes the env var -hasApiKey(); // boolean +import { OMOPHub, type Concept } from '@omophub/omophub-node'; + +const client = new OMOPHub(); +const { data, error } = await client.concepts.get(201826); +if (error) throw new Error(error.message); + +// `data` is narrowed to `Concept` +const c: Concept = data; +c.concept_id; // number +c.concept_name; // string +c.vocabulary_id; // string +c.domain_id; // string +c.concept_class_id; // string +c.standard_concept; // 'S' | 'C' | 'N' | null ``` -## Migration from the Python or R SDK +Request options are camelCase TypeScript; wire fields are snake_case. The SDK converts between them at the request boundary - you write `{ vocabularyIds: ['SNOMED'] }` and the server sees `vocabulary_ids=SNOMED`. + +## Runtime Support -| Python | Node | Notes | +| Runtime | Status | Notes | |---|---|---| -| `client.concepts.get(201826)` | `client.concepts.get(201826)` | Same | -| `client.concepts.batch(concept_ids=[1,2])` | `client.concepts.batch({ conceptIds: [1,2] })` | Options object | -| `client.search.basic(query, vocabulary_ids=[...])` | `client.search.basic(query, { vocabularyIds: [...] })` | camelCase options | -| `client.mappings.map('SNOMED', source_concepts=[1])` | `client.mappings.map({ targetVocabulary: 'SNOMED', sourceConcepts: [1] })` | All in options | -| `client.fhir.resolve(system='...', code='...')` | `client.fhir.resolve({ system: '...', code: '...' })` | Single options object | +| Node.js ≥ 22 | First-class | Primary target | +| Deno ≥ 1.40 | First-class | Native `fetch`, ESM-only | +| Bun ≥ 1.0 | First-class | Native `fetch`, ESM-only | +| Cloudflare Workers | Supported | Pure ESM, no Node built-ins required | +| Vercel Edge | Supported | Same as Workers | +| Browser | Supported | CORS configured on `api.omophub.com` | + +## Compared to Alternatives + +| Feature | OMOPHub SDK | ATHENA Download | OHDSI WebAPI | +|---------|-------------|-----------------|--------------| +| Setup time | 1 minute | Hours | Hours | +| Infrastructure | None | Database required | Full OHDSI stack | +| Updates | Automatic | Manual download | Manual | +| TypeScript types | First-class | None | Generated from OpenAPI | +| Edge runtime support | Yes | N/A | No | + +**Best for:** Teams who need quick, type-safe access to OMOP vocabularies from JavaScript/TypeScript without infrastructure overhead. + +## Documentation -Wire-format snake_case is preserved on **response** types (`data.concept_id`, `data.vocabulary_id`) — same as Python and R. Option keys are camelCase in TS and converted to snake_case at the request boundary. +- [Full Documentation](https://docs.omophub.com/sdks/node/overview) +- [API Reference](https://docs.omophub.com/api-reference) +- [Examples](https://github.com/OMOPHub/omophub-node/tree/main/examples) +- [Get API Key](https://dashboard.omophub.com/api-keys) + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +```bash +# Clone and install for development +git clone https://github.com/OMOPHub/omophub-node.git +cd omophub-node +npm install + +# Run tests +npm test # unit tests +npm run test:e2e # integration tests (requires OMOPHUB_API_KEY) +npm run typecheck && npm run lint && npm run build +``` + +## Support + +- [GitHub Issues](https://github.com/OMOPHub/omophub-node/issues) +- [GitHub Discussions](https://github.com/OMOPHub/omophub-node/discussions) +- Email: support@omophub.com +- Website: [omophub.com](https://omophub.com) ## License -MIT — see [LICENSE](./LICENSE). +MIT License - see [LICENSE](./LICENSE) for details. + +--- + +*Built for the OHDSI community* diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..69a680d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,76 @@ +# OMOPHub Node SDK — Examples + +Runnable TypeScript examples covering the full SDK surface. Each file is self-contained: import the SDK, call a few methods, print results. + +## Setup + +```bash +# 1. From the repo root, install dev deps if you haven't +npm install + +# 2. Either set the env var inline or copy the loader from vitest.e2e.config.ts +export OMOPHUB_API_KEY=oh_your_api_key +``` + +## Running an example + +Examples run directly via `tsx` (already a dev-dep — no build step): + +```bash +npx tsx examples/basic-usage.ts +npx tsx examples/error-handling.ts +npx tsx examples/search-concepts.ts +npx tsx examples/navigate-hierarchy.ts +npx tsx examples/map-between-vocabularies.ts +npx tsx examples/fhir-resolver.ts +``` + +## Files + +| File | What it covers | +|---|---| +| [`basic-usage.ts`](./basic-usage.ts) | `concepts.get`, `search.basic`, `vocabularies.list` — quickstart | +| [`error-handling.ts`](./error-handling.ts) | Discriminated `{ data, error }` pattern, every error code, iterator vs. eager error modes, `OMOPHubError` on misuse | +| [`search-concepts.ts`](./search-concepts.ts) | basic / filtered / autocomplete / semantic / bulk / similar + async iterator pagination | +| [`navigate-hierarchy.ts`](./navigate-hierarchy.ts) | `hierarchy.ancestors`, `hierarchy.descendants`, `hierarchy.get` (graph format), `relationships.get` | +| [`map-between-vocabularies.ts`](./map-between-vocabularies.ts) | `mappings.get`, `mappings.map` (concepts + native codes), code-lookup-then-map | +| [`fhir-resolver.ts`](./fhir-resolver.ts) | SNOMED/LOINC/RxNorm/ICD-10-CM resolution, recommendations, quality, batch, CodeableConcept, coding-object form, `omophubFhirUrl` | + +## SDK conventions used throughout + +**Returns over throws.** Every method returns `{ data, error, meta, headers }` — narrow with `if (error) ...` and TypeScript types `data` correctly: + +```ts +const { data, error } = await client.concepts.get(201826); +if (error) { + console.error(error.name, error.message); + return; +} +console.log(data.concept_name); // narrowed to Concept +``` + +**camelCase in, snake_case on the wire.** The SDK accepts camelCase option keys and converts to snake_case at the HTTP boundary. Response field names stay snake_case (matches the wire). + +**Async iterators throw.** `*Iter` variants throw `OMOPHubIteratorError` on page failure (generators can't gracefully yield discriminated errors). Use `*All` if you prefer error accumulation: + +```ts +// Throws on first failure: +for await (const c of client.search.basicIter('diabetes')) { ... } + +// Accumulates errors: +const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { maxPages: 5 }); +``` + +**The only thing that throws.** `OMOPHubError` from the `OMOPHub` constructor when no API key is supplied. Network / API / validation errors are all return values. + +## Testing examples + +Examples run against the live API at `https://api.omophub.com/v1`. To verify they execute end-to-end on your key: + +```bash +export OMOPHUB_API_KEY=oh_... +for f in examples/*.ts; do + echo "--- $f ---" + npx tsx "$f" || echo "FAILED: $f" +done +``` diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts new file mode 100644 index 0000000..3dc1db4 --- /dev/null +++ b/examples/basic-usage.ts @@ -0,0 +1,49 @@ +/** + * Basic usage example for the OMOPHub Node SDK. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/basic-usage.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function main(): Promise { + // Reads OMOPHUB_API_KEY from the environment. To pass it explicitly: + // const client = new OMOPHub('oh_your_api_key'); + const client = new OMOPHub(); + + // Get a concept by ID + const { data: concept, error } = await client.concepts.get(201826); + if (error) throw new Error(error.message); + console.log(`Concept: ${concept.concept_name}`); + console.log(` Vocabulary: ${concept.vocabulary_id}`); + console.log(` Code: ${concept.concept_code}`); + console.log(` Domain: ${concept.domain_id}`); + console.log(); + + // Search for concepts — `data.concepts` is always a `Concept[]` after the + // SDK normalises the wire shape. + const results = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + if (results.error) throw new Error(results.error.message); + console.log("Search results for 'diabetes':"); + for (const c of results.data.concepts) { + console.log(` ${c.concept_id}: ${c.concept_name}`); + } + console.log(); + + // List vocabularies — `data.vocabularies` is the array; pagination meta + // sits on the outer `result.meta.pagination`. + const vocabs = await client.vocabularies.list({ pageSize: 5 }); + if (vocabs.error) throw new Error(vocabs.error.message); + console.log('Available vocabularies:'); + for (const v of vocabs.data.vocabularies) { + console.log(` ${v.vocabulary_id}: ${v.vocabulary_name}`); + } +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/error-handling.ts b/examples/error-handling.ts new file mode 100644 index 0000000..9ac905e --- /dev/null +++ b/examples/error-handling.ts @@ -0,0 +1,179 @@ +/** + * Error handling examples for the OMOPHub Node SDK. + * + * Unlike SDKs that throw on API errors, every method here returns a + * discriminated `{ data, error, meta, headers }` union. Narrow with + * `if (error) ...` and TypeScript will type `data` correctly in the + * else branch. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/error-handling.ts + */ +import { OMOPHub, OMOPHubError, OMOPHubIteratorError } from '../src/index.js'; + +async function handleNotFound(): Promise { + console.log('=== Handling Not Found ==='); + const client = new OMOPHub(); + const { data, error } = await client.concepts.get(999_999_999); + if (error) { + console.log(`Concept not found: ${error.message}`); + console.log(` Code: ${error.name}`); + console.log(` Status: ${error.statusCode}`); + if (error.requestId) console.log(` Request ID: ${error.requestId}`); + return; + } + console.log(`Found: ${data.concept_name}`); +} + +async function handleAuthentication(): Promise { + console.log('\n=== Handling Authentication Errors ==='); + // Bypass env by passing an explicit (bad) key + const client = new OMOPHub('oh_invalid_key', { maxRetries: 0 }); + const { error } = await client.concepts.get(201826); + if (error) { + console.log(`Authentication failed: ${error.message}`); + console.log(` Code: ${error.name}`); // invalid_api_key | restricted_api_key + console.log(` Status: ${error.statusCode}`); + } +} + +async function handleRateLimit(): Promise { + console.log('\n=== Handling Rate Limits ==='); + // The SDK retries 429 automatically up to `maxRetries` with backoff. + // For demo, disable retries to surface the error so we can inspect it. + const client = new OMOPHub(undefined, { maxRetries: 0 }); + for (let i = 0; i < 3; i++) { + const { error } = await client.search.basic('diabetes', { pageSize: 1 }); + if (error?.name === 'rate_limit_exceeded') { + console.log(`Rate limited! Retry after ${error.retryAfter ?? '?'}s`); + await new Promise((r) => setTimeout(r, (error.retryAfter ?? 1) * 1000)); + continue; + } + if (error) { + console.log(` Request ${i + 1}: ${error.name}: ${error.message}`); + return; + } + console.log(` Request ${i + 1} succeeded`); + } +} + +async function handleValidation(): Promise { + console.log('\n=== Handling Validation Errors ==='); + const client = new OMOPHub(); + // Trigger synthetic client-side validation — no network call. + const { error } = await client.concepts.batch({ conceptIds: [] }); + if (error) { + console.log(`Validation error: ${error.message}`); + console.log(` Code: ${error.name}`); // validation_error + console.log(` Status: ${error.statusCode}`); // null — synthetic + } +} + +/** + * The full set of error codes you can switch on. See + * `OMOPHUB_ERROR_CODE_KEY` in the SDK for the canonical list. + */ +async function comprehensiveErrorHandling(): Promise { + console.log('\n=== Comprehensive Error Handling ==='); + const client = new OMOPHub(); + const { data, error } = await client.concepts.get(201826); + + if (error) { + switch (error.name) { + case 'invalid_api_key': + case 'missing_api_key': + case 'restricted_api_key': + console.log(`Auth error: ${error.message}`); + break; + case 'not_found': + console.log(`Not found: ${error.message}`); + break; + case 'rate_limit_exceeded': + case 'tier_limit_exceeded': + console.log(`Rate / tier limited, retry after: ${error.retryAfter ?? '?'}s`); + break; + case 'validation_error': + case 'missing_required_field': + case 'invalid_argument': + console.log(`Invalid request: ${error.message}`); + break; + case 'service_unavailable': + case 'internal_server_error': + console.log(`Server error: ${error.message}`); + break; + case 'connection_error': + console.log(`Network error: ${error.message}`); + break; + case 'timeout_error': + console.log(`Request timed out: ${error.message}`); + break; + default: + console.log(`SDK error: ${error.name}: ${error.message}`); + } + return; + } + + console.log(`Success: ${data.concept_name}`); +} + +/** + * Async iterators throw `OMOPHubIteratorError` on page failure (they can't + * gracefully yield discriminated errors). Catch and inspect like any + * other Error subclass. + */ +async function handleIteratorErrors(): Promise { + console.log('\n=== Iterator Error Handling ==='); + const client = new OMOPHub('oh_definitely_bad_key', { maxRetries: 0 }); + try { + for await (const c of client.search.basicIter('diabetes', { pageSize: 5 })) { + console.log(` ${c.concept_name}`); // will not reach + } + } catch (e) { + if (e instanceof OMOPHubIteratorError) { + console.log(`Iterator failed: ${e.code} (${e.statusCode}): ${e.message}`); + } else { + throw e; + } + } +} + +/** + * The eager `*All` variant accumulates errors as values rather than throwing. + */ +async function handleEagerErrors(): Promise { + console.log('\n=== Eager Collect with Error Accumulation ==='); + const client = new OMOPHub('oh_definitely_bad_key', { maxRetries: 0 }); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 5, + maxPages: 2, + }); + console.log(` Collected ${data.length} items across ${pagesFetched} page(s)`); + console.log(` Errors: ${errors.length}`); + for (const e of errors) console.log(` ${e.name}: ${e.message}`); +} + +async function main(): Promise { + await handleNotFound(); + await handleAuthentication(); + await handleValidation(); + await comprehensiveErrorHandling(); + await handleIteratorErrors(); + await handleEagerErrors(); + await handleRateLimit(); + + // Demonstrate that constructor misuse THROWS OMOPHubError (the only + // exception in the SDK — everything else is a return value). + try { + delete process.env.OMOPHUB_API_KEY; + new OMOPHub(); + } catch (e) { + if (e instanceof OMOPHubError) { + console.log(`\nConstructor threw OMOPHubError: ${e.message}`); + } + } +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/fhir-resolver.ts b/examples/fhir-resolver.ts new file mode 100644 index 0000000..18b6713 --- /dev/null +++ b/examples/fhir-resolver.ts @@ -0,0 +1,288 @@ +/** + * FHIR Resolver examples for the OMOPHub Node SDK. + * + * Translates FHIR coded values (system URI + code) into OMOP standard + * concepts, CDM target tables, and optional Phoebe recommendations — all + * in a single API call. + * + * Covers: + * 1. Direct SNOMED resolution + * 2. ICD-10-CM → SNOMED via "Maps to" + * 3. LOINC → measurement table + * 4. RxNorm → drug_exposure table + * 5. Text-only semantic search fallback + * 6. vocabularyId bypass (skip URI resolution) + * 7. Phoebe recommendations + * 8. Mapping quality signal + * 9. Batch resolution + * 10. CodeableConcept resolution + * 11. Coding-object input form (vs. flat fields) + * 12. FHIR Terminology Service URL helper + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/fhir-resolver.ts + */ +import { OMOPHub, omophubFhirUrl } from '../src/index.js'; + +// ─── 1. Direct SNOMED resolution ───────────────────────────────────── + +async function resolveSnomed(client: OMOPHub): Promise { + console.log('=== 1. SNOMED Direct Resolution ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Source: ${r.source_concept.concept_name}`); + console.log(` Standard: ${r.standard_concept.concept_name}`); + console.log(` Mapping type: ${r.mapping_type}`); // "direct" + console.log(` Target table: ${r.target_table}`); // "condition_occurrence" +} + +// ─── 2. ICD-10-CM → SNOMED via "Maps to" ───────────────────────────── + +async function resolveIcd10Mapped(client: OMOPHub): Promise { + console.log('\n=== 2. ICD-10-CM → SNOMED Mapping ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Source: [${r.source_concept.vocabulary_id}] ${r.source_concept.concept_name}`); + console.log( + ` Standard: [${r.standard_concept.vocabulary_id}] ${r.standard_concept.concept_name}`, + ); + console.log(` Mapping type: ${r.mapping_type}`); // "mapped" or similar + console.log(` Target table: ${r.target_table}`); +} + +// ─── 3. LOINC → measurement table ──────────────────────────────────── + +async function resolveLoinc(client: OMOPHub): Promise { + console.log('\n=== 3. LOINC → Measurement ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://loinc.org', + code: '2339-0', // Glucose [Mass/volume] in Blood + resourceType: 'Observation', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Concept: ${data.resolution.standard_concept.concept_name}`); + console.log(` Target table: ${data.resolution.target_table}`); // "measurement" +} + +// ─── 4. RxNorm → drug_exposure ────────────────────────────────────── + +async function resolveRxNorm(client: OMOPHub): Promise { + console.log('\n=== 4. RxNorm → Drug Exposure ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://www.nlm.nih.gov/research/umls/rxnorm', + code: '197696', // Metformin 500 MG Oral Tablet + resourceType: 'MedicationStatement', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Concept: ${data.resolution.standard_concept.concept_name}`); + console.log(` Target table: ${data.resolution.target_table}`); // "drug_exposure" +} + +// ─── 5. Text-only semantic fallback ────────────────────────────────── + +async function resolveTextOnly(client: OMOPHub): Promise { + console.log('\n=== 5. Text-Only (Semantic Fallback) ==='); + const { data, error } = await client.fhir.resolve({ + display: 'Blood Sugar', + resourceType: 'Observation', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Standard: ${r.standard_concept.concept_name}`); + console.log(` Mapping type: ${r.mapping_type}`); // "semantic_match" or similar + if (r.similarity_score !== undefined) { + console.log(` Similarity: ${r.similarity_score.toFixed(2)}`); + } +} + +// ─── 6. vocabularyId bypass (skip URI resolution) ──────────────────── + +async function resolveVocabularyIdBypass(client: OMOPHub): Promise { + console.log('\n=== 6. vocabularyId Bypass ==='); + // If you already know the OMOP vocabulary, skip the system-URI lookup. + const { data, error } = await client.fhir.resolve({ + vocabularyId: 'ICD10CM', + code: 'E11.9', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Resolved: ${data.resolution.standard_concept.concept_name}`); +} + +// ─── 7. Phoebe recommendations ─────────────────────────────────────── + +async function resolveWithRecommendations(client: OMOPHub): Promise { + console.log('\n=== 7. With Phoebe Recommendations ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeRecommendations: true, + recommendationsLimit: 5, + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const recs = data.resolution.recommendations ?? []; + console.log(` Got ${recs.length} recommendations:`); + for (const r of recs.slice(0, 5)) { + console.log(` ${r.concept_name} (${r.domain_id ?? '?'})`); + } +} + +// ─── 8. Mapping quality signal ─────────────────────────────────────── + +async function resolveWithQuality(client: OMOPHub): Promise { + console.log('\n=== 8. With Quality Signal ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeQuality: true, + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Mapping quality: ${data.resolution.mapping_quality ?? '?'}`); + // Common buckets: "high", "medium", "low", "manual_review" +} + +// ─── 9. Batch resolution (up to 100 codings) ───────────────────────── + +async function resolveBatchExample(client: OMOPHub): Promise { + console.log('\n=== 9. Batch Resolution ==='); + const { data, error } = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, + { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '197696' }, + ], + { includeQuality: true }, + ); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Summary: ${data.summary.resolved}/${data.summary.total} resolved`); + for (const item of data.results) { + const r = item.resolution; + console.log( + ` ${r.source_concept.concept_code} → ${r.standard_concept.concept_name} → ${r.target_table}`, + ); + } +} + +// ─── 10. CodeableConcept resolution (up to 20 codings) ─────────────── + +async function resolveCodeableConceptExample(client: OMOPHub): Promise { + console.log('\n=== 10. CodeableConcept Resolution ==='); + // The resolver picks the best match per OHDSI vocabulary preference + // (SNOMED > RxNorm > LOINC > CVX > ICD10). + const { data, error } = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + if (data.best_match) { + // best_match is wrapped as { input, resolution } — same shape + // `fhir.resolve()` returns for a single coding. + const r = data.best_match.resolution; + console.log( + ` Best match: ${r.standard_concept.concept_name} (${r.source_concept.vocabulary_id})`, + ); + } + console.log(` Alternatives: ${data.alternatives.length}`); + console.log(` Unresolved: ${data.unresolved.length}`); +} + +// ─── 11. Coding-object input form ──────────────────────────────────── + +async function resolveWithCodingObject(client: OMOPHub): Promise { + console.log('\n=== 11. Coding-Object Input ==='); + // Equivalent to the flat form — useful when you have a FHIR `Coding` + // already serialized as an object. + const { data, error } = await client.fhir.resolve({ + coding: { + system: 'http://snomed.info/sct', + code: '44054006', + display: 'Type 2 diabetes mellitus', + userSelected: true, + }, + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Resolved: ${data.resolution.standard_concept.concept_name}`); + // Flat fields take precedence when both are supplied — handy for + // patching a single field without rebuilding the coding object. +} + +// ─── 12. FHIR Terminology Service URL helper ───────────────────────── + +function fhirServiceUrls(): void { + console.log('\n=== 12. FHIR Terminology Service URLs ==='); + console.log(` R4 : ${omophubFhirUrl()}`); // default + console.log(` R4B: ${omophubFhirUrl('r4b')}`); + console.log(` R5 : ${omophubFhirUrl('r5')}`); + console.log(` R6 : ${omophubFhirUrl('r6')}`); + // Point your favourite FHIR client at these to hit the raw FHIR endpoints + // (CodeSystem/$lookup, ValueSet/$expand, etc.) outside the OMOP envelope. +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await resolveSnomed(client); + await resolveIcd10Mapped(client); + await resolveLoinc(client); + await resolveRxNorm(client); + await resolveTextOnly(client); + await resolveVocabularyIdBypass(client); + await resolveWithRecommendations(client); + await resolveWithQuality(client); + await resolveBatchExample(client); + await resolveCodeableConceptExample(client); + await resolveWithCodingObject(client); + fhirServiceUrls(); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/map-between-vocabularies.ts b/examples/map-between-vocabularies.ts new file mode 100644 index 0000000..79f89c7 --- /dev/null +++ b/examples/map-between-vocabularies.ts @@ -0,0 +1,123 @@ +/** + * Map concepts between vocabularies using the OMOPHub Node SDK. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/map-between-vocabularies.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function getMappings(): Promise { + console.log('=== Concept Mappings ==='); + const client = new OMOPHub(); + + // Type 2 diabetes mellitus (SNOMED) + const conceptId = 201826; + const { data, error } = await client.mappings.get(conceptId, { + targetVocabulary: 'ICD10CM', + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + console.log(`Mappings for concept ${conceptId} → ICD10CM:`); + console.log(` Total mappings: ${data.summary?.total_mappings ?? data.mappings.length}`); + + for (const m of data.mappings.slice(0, 10)) { + console.log(`\n [${m.target_vocabulary_id}] ${m.target_concept_code}`); + console.log(` Name: ${m.target_concept_name}`); + console.log(` Type: ${m.mapping_type}`); + } +} + +async function mapConcepts(): Promise { + console.log('\n=== Batch Concept Mapping ==='); + const client = new OMOPHub(); + + // Map SNOMED concepts to ICD-10-CM + const { data, error } = await client.mappings.map({ + targetVocabulary: 'ICD10CM', + sourceConcepts: [201826, 4329847], // Type 2 diabetes, Myocardial infarction + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + console.log(`Mapped ${data.mappings.length} concepts to ICD-10-CM`); + if (data.summary) { + console.log( + ` ${data.summary.mapped_concepts ?? '?'}/${data.summary.total_source_concepts ?? '?'} source concepts mapped`, + ); + } + + for (const m of data.mappings) { + console.log(`\n ${m.source_concept_name}`); + console.log(` → [${m.target_concept_code}] ${m.target_concept_name}`); + } +} + +async function mapByNativeCode(): Promise { + console.log('\n=== Map by Native Vocabulary Codes ==='); + const client = new OMOPHub(); + + // Map ICD-10-CM codes directly (no need to resolve to OMOP IDs first) + const { data, error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, // Type 2 diabetes w/o complications + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, // Essential hypertension + ], + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + for (const m of data.mappings) { + console.log(` ${m.source_vocabulary_id} ${m.source_concept_code}`); + console.log(` → ${m.target_concept_name} (${m.target_vocabulary_id})`); + } +} + +async function lookupByCode(): Promise { + console.log('\n=== Code Lookup and Mapping ==='); + const client = new OMOPHub(); + + // Look up ICD-10-CM code E11 (Type 2 diabetes mellitus) + const { data: concept, error: lookupErr } = await client.concepts.getByCode('ICD10CM', 'E11'); + if (lookupErr) { + console.log(`Lookup failed: ${lookupErr.message}`); + return; + } + + console.log(`Found: ${concept.concept_name}`); + console.log(` Vocabulary: ${concept.vocabulary_id}`); + console.log(` Standard: ${concept.standard_concept ?? 'N/A'}`); + + // If it's not a standard concept, find its mappings + if (concept.standard_concept !== 'S') { + const { data: mappings, error: mapErr } = await client.mappings.get(concept.concept_id); + if (mapErr) { + console.log(` Mappings failed: ${mapErr.message}`); + return; + } + console.log('\n Mappings to other vocabularies:'); + for (const m of mappings.mappings.slice(0, 5)) { + const vocab = m.target_vocabulary_id ?? '?'; + console.log(` → ${m.target_concept_name} (${vocab})`); + } + } +} + +async function main(): Promise { + await getMappings(); + await mapConcepts(); + await mapByNativeCode(); + await lookupByCode(); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/navigate-hierarchy.ts b/examples/navigate-hierarchy.ts new file mode 100644 index 0000000..804a159 --- /dev/null +++ b/examples/navigate-hierarchy.ts @@ -0,0 +1,116 @@ +/** + * Navigate concept hierarchies using the OMOPHub Node SDK. + * + * Demonstrates: ancestors, descendants, and relationship grouping. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/navigate-hierarchy.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function getAncestors(client: OMOPHub): Promise { + console.log('=== Concept Ancestors ==='); + // Type 2 diabetes mellitus (SNOMED) + const conceptId = 201826; + + const { data, error } = await client.hierarchy.ancestors(conceptId, { + maxLevels: 5, + includeDistance: true, + }); + if (error) throw new Error(error.message); + + console.log(`Ancestors of concept ${conceptId}:`); + for (const a of data.ancestors.slice(0, 10)) { + const level = a.min_levels_of_separation ?? a.level ?? '?'; + console.log(` Level ${level}: ${a.concept_name}`); + } +} + +async function getDescendants(client: OMOPHub): Promise { + console.log('\n=== Concept Descendants ==='); + // Diabetes mellitus (SNOMED — broader concept) + const conceptId = 201820; + + const { data, error } = await client.hierarchy.descendants(conceptId, { + maxLevels: 2, + includeInvalid: false, + }); + if (error) throw new Error(error.message); + + console.log(`Descendants of concept ${conceptId}:`); + for (const d of data.descendants.slice(0, 10)) { + const level = d.min_levels_of_separation ?? d.level ?? '?'; + console.log(` Level ${level}: ${d.concept_name}`); + } +} + +async function exploreRelationships(client: OMOPHub): Promise { + console.log('\n=== Concept Relationships ==='); + // Aspirin + const conceptId = 1112807; + + const { data, error, meta } = await client.relationships.get(conceptId, { + pageSize: 100, + }); + if (error) throw new Error(error.message); + + console.log(`Relationships for concept ${conceptId}:`); + console.log(` Total: ${meta?.pagination?.total_items ?? data.relationships.length}`); + + // Group by relationship type + const byType = new Map(); + for (const r of data.relationships) { + const list = byType.get(r.relationship_id) ?? []; + list.push(r); + byType.set(r.relationship_id, list); + } + + let shown = 0; + for (const [relType, rels] of byType) { + if (shown >= 5) break; + console.log(`\n ${relType}:`); + for (const r of rels.slice(0, 3)) { + // The wire embeds the related concept under `concept_2` (the queried + // concept itself is `concept_1`). + console.log(` → ${r.concept_2?.concept_name ?? '?'}`); + } + shown++; + } +} + +/** + * Bonus: get the full hierarchy graph in one call (flat or graph format). + */ +async function getFullHierarchy(client: OMOPHub): Promise { + console.log('\n=== Full Hierarchy (graph format) ==='); + const { data, error } = await client.hierarchy.get(201826, { + format: 'graph', + maxLevels: 2, + vocabularyIds: ['SNOMED'], + }); + if (error) { + // Server may validate maxLevels strictly; tolerate non-network errors. + console.log(` Hierarchy returned ${error.name}: ${error.message}`); + return; + } + console.log(` Nodes: ${data.nodes?.length ?? 0}, Edges: ${data.edges?.length ?? 0}`); + if (data.summary) { + console.log(` Max depth: ${data.summary.max_hierarchy_depth}`); + console.log(` Unique vocabularies: ${data.summary.unique_vocabularies}`); + } +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await getAncestors(client); + await getDescendants(client); + await exploreRelationships(client); + await getFullHierarchy(client); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/search-concepts.ts b/examples/search-concepts.ts new file mode 100644 index 0000000..d87fab4 --- /dev/null +++ b/examples/search-concepts.ts @@ -0,0 +1,205 @@ +/** + * Search examples for the OMOPHub Node SDK. + * + * Demonstrates: basic search, filtered search, autocomplete, pagination + * via async iterator, semantic search, similarity search, bulk lexical + * search, and bulk semantic search. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/search-concepts.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function basicSearch(client: OMOPHub): Promise { + console.log('=== Basic Search ==='); + // `search.basic` normalises three known wire shapes into a stable + // `{ concepts, facets?, search_metadata? }` object. + const { data, error } = await client.search.basic('heart attack'); + if (error) throw new Error(error.message); + console.log(`Found ${data.concepts.length} concepts for 'heart attack'`); + for (const c of data.concepts.slice(0, 3)) { + console.log(` ${c.concept_id}: ${c.concept_name} (${c.vocabulary_id})`); + } +} + +async function filteredSearch(client: OMOPHub): Promise { + console.log('\n=== Filtered Search ==='); + const { data, error } = await client.search.basic('myocardial infarction', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + domainIds: ['Condition'], + standardConcept: 'S', // only standard concepts + pageSize: 10, + }); + if (error) throw new Error(error.message); + console.log(`Found ${data.concepts.length} standard condition concepts`); + for (const c of data.concepts.slice(0, 5)) { + console.log(` [${c.vocabulary_id}] ${c.concept_name}`); + } +} + +async function bulkLexicalSearch(client: OMOPHub): Promise { + console.log('\n=== Bulk Lexical Search ==='); + // `bulkBasic` returns a **bare array** of per-search result items. + const { data, error } = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes mellitus' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, + ); + if (error) throw new Error(error.message); + for (const item of data) { + console.log(` ${item.search_id}: ${item.results.length} results (${item.status})`); + } + + // Per-query overrides + const r2 = await client.search.bulkBasic( + [ + { search_id: 'conditions', query: 'diabetes', domain_ids: ['Condition'] }, + { search_id: 'drugs', query: 'metformin', domain_ids: ['Drug'] }, + ], + { defaults: { vocabulary_ids: ['SNOMED', 'RxNorm'], page_size: 3 } }, + ); + console.log('\n Per-query domain overrides:'); + for (const item of r2.data ?? []) { + console.log(` ${item.search_id}:`); + for (const c of item.results.slice(0, 3)) { + console.log(` ${c.concept_name} (${c.vocabulary_id}/${c.domain_id})`); + } + } +} + +async function bulkSemanticSearch(client: OMOPHub): Promise { + console.log('\n=== Bulk Semantic Search ==='); + // `bulkSemantic` returns a wrapper object — UNLIKE `bulkBasic`. + const { data, error } = await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart failure treatment options' }, + { search_id: 's2', query: 'type 2 diabetes medication' }, + { search_id: 's3', query: 'elevated blood pressure' }, + ], + { defaults: { threshold: 0.5, page_size: 5 } }, + ); + if (error) throw new Error(error.message); + console.log( + ` Completed ${data.completed_count}/${data.total_searches}` + + (data.total_duration !== undefined ? ` in ${data.total_duration}ms` : ''), + ); + for (const item of data.results) { + console.log(` ${item.search_id}: ${item.results.length} results (${item.status})`); + if (item.results.length > 0) { + const top = item.results[0]; + console.log( + ` Top: ${top?.concept_name} (score: ${top?.similarity_score.toFixed(2)})`, + ); + } + } +} + +async function autocompleteExample(client: OMOPHub): Promise { + console.log('\n=== Autocomplete ==='); + // Returns `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }`. + const { data, error } = await client.search.autocomplete('hypert', { pageSize: 5 }); + if (error) throw new Error(error.message); + console.log(`Suggestions for '${data.query}':`); + for (const s of data.suggestions.slice(0, 5)) { + console.log(` [${s.suggestion.vocabulary_id}] ${s.suggestion.concept_name}`); + } +} + +async function paginationExample(client: OMOPHub): Promise { + console.log('\n=== Pagination (async iterator) ==='); + // `basicIter` walks every page; throws OMOPHubIteratorError on failure. + let count = 0; + for await (const concept of client.search.basicIter('aspirin', { pageSize: 10 })) { + if (count < 3) console.log(` ${concept.concept_name}`); + count++; + if (count >= 25) break; // demo cap + } + if (count > 3) { + console.log(` ... ${count - 3} more shown (demo capped at ${count})`); + } +} + +async function semanticSearch(client: OMOPHub): Promise { + console.log('\n=== Semantic Search ==='); + // Natural-language search — understands clinical intent. + const a = await client.search.semantic('high blood sugar levels'); + if (a.error) throw new Error(a.error.message); + for (const r of a.data.results.slice(0, 3)) { + console.log(` ${r.concept_name} (similarity: ${r.similarity_score.toFixed(2)})`); + } + + // Filtered semantic search with minimum threshold + const b = await client.search.semantic('heart attack', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + threshold: 0.5, + }); + if (b.error) throw new Error(b.error.message); + console.log(` Found ${b.data.results.length} SNOMED conditions for 'heart attack'`); +} + +async function semanticPagination(client: OMOPHub): Promise { + console.log('\n=== Semantic Pagination ==='); + let count = 0; + for await (const result of client.search.semanticIter('chronic kidney disease', { + pageSize: 20, + })) { + if (count < 3) console.log(` ${result.concept_id}: ${result.concept_name}`); + count++; + if (count >= 50) break; + } + if (count > 3) { + console.log(` ... ${count - 3} more (demo capped at ${count})`); + } +} + +async function similaritySearch(client: OMOPHub): Promise { + console.log('\n=== Similarity Search ==='); + // XOR: provide exactly one of `conceptId`, `conceptName`, or `query`. + // The discriminated union enforces this at compile time. + const a = await client.search.similar({ + conceptId: 201826, // Type 2 diabetes mellitus + algorithm: 'hybrid', + }); + if (a.error) throw new Error(a.error.message); + console.log("Concepts similar to 'Type 2 diabetes mellitus':"); + for (const r of a.data.similar_concepts.slice(0, 5)) { + const score = typeof r.similarity_score === 'number' ? r.similarity_score.toFixed(2) : '?'; + console.log(` ${r.concept_name} (score: ${score})`); + } + + // Free-text query variant (`similar` uses a two-arg signature so its + // `query` field doesn't collide with `PerCallOptions.query`). + const b = await client.search.similar({ + query: 'medications for high blood pressure', + algorithm: 'semantic', + similarityThreshold: 0.6, + vocabularyIds: ['RxNorm'], + includeScores: true, + }); + if (b.error) throw new Error(b.error.message); + console.log(`\n Found ${b.data.similar_concepts.length} similar RxNorm concepts`); +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await basicSearch(client); + await filteredSearch(client); + await autocompleteExample(client); + await paginationExample(client); + await semanticSearch(client); + await semanticPagination(client); + await similaritySearch(client); + await bulkLexicalSearch(client); + await bulkSemanticSearch(client); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index d89e1c9..8f67a32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,27 +11,13 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "@types/node": "^25.0.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.7", "tsx": "^4.19.0", - "typescript": "^5.9.3", - "vitest": "^3.2.4" + "typescript": "^6.0.3", + "vitest": "^4.1.7" }, "engines": { - "node": ">=20" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "node": ">=22" } }, "node_modules/@babel/helper-string-parser": { @@ -257,6 +243,40 @@ "node": ">=14.21.3" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -699,45 +719,6 @@ "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -766,35 +747,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, - "engines": { - "node": ">=14" + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -803,12 +788,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -817,12 +805,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -831,26 +822,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -859,26 +839,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -887,26 +856,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -915,54 +873,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -971,40 +907,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1013,12 +924,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1027,12 +941,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1041,26 +958,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1069,40 +975,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1111,21 +1028,35 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tslib": "^2.4.0" + } }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1163,32 +1094,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1197,39 +1125,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1241,42 +1170,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1284,59 +1213,30 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1348,9 +1248,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1359,147 +1259,37 @@ "js-tokens": "^10.0.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -1583,23 +1373,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1615,61 +1388,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1687,23 +1405,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -1729,21 +1430,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -1758,22 +1444,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -1781,161 +1451,342 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BlueOak-1.0.0", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 14.16" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1985,57 +1836,39 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } }, "node_modules/semver": { "version": "7.8.1", @@ -2050,29 +1883,6 @@ "node": ">=10" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2080,19 +1890,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2111,133 +1908,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -2254,21 +1927,6 @@ "node": ">=8" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2277,16 +1935,19 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2300,35 +1961,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } + "license": "0BSD", + "optional": true }, "node_modules/tsx": { "version": "4.22.3", @@ -2350,9 +1999,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2371,18 +2020,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2398,9 +2046,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2413,13 +2062,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -2445,573 +2097,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite-node": "vite-node.mjs" + "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "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" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@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": { @@ -3022,25 +2181,12 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3057,104 +2203,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } } } } diff --git a/package.json b/package.json index 58a1a23..89de8bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@omophub/omophub-node", - "version": "0.0.1", + "version": "1.0.0", "description": "Official OMOPHub Node.js / TypeScript SDK for the OHDSI medical vocabularies API - search, lookup, map, and navigate concepts across SNOMED, ICD10, RxNorm, LOINC, and more.", "type": "module", "main": "dist/index.js", @@ -58,9 +58,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "@types/node": "^25.0.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.7", "tsx": "^4.19.0", - "typescript": "^5.9.3", - "vitest": "^3.2.4" + "typescript": "^6.0.3", + "vitest": "^4.1.7" } } diff --git a/src/concepts/interfaces/concept.ts b/src/concepts/interfaces/concept.ts index 1f6cc4e..85110bc 100644 --- a/src/concepts/interfaces/concept.ts +++ b/src/concepts/interfaces/concept.ts @@ -19,22 +19,41 @@ export interface ConceptSummary { concept_code: string; } +/** + * Concept payload nested inside a `ConceptRelationship` row under + * `concept_1` / `concept_2`. Slightly richer than `ConceptSummary` — + * the relationships endpoint embeds the full concept metadata. + */ +export interface RelationshipConcept extends ConceptSummary { + vocabulary_name?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + valid_start_date?: string; + valid_end_date?: string; + invalid_reason?: string | null; +} + /** * Full relationship row returned by the dedicated relationships * endpoints (`GET /concepts/{id}/relationships`, `relationships.get(id)`, - * `concepts.relationships(id)`). Carries the target concept's full metadata. + * `concepts.relationships(id)`). + * + * **Wire shape uses `concept_1` / `concept_2`** — NOT a flat `target_*` + * projection. For an `aspirin → Halfprin` "Has brand name" row, + * `concept_1` is aspirin (the queried side) and `concept_2` is Halfprin + * (the related side). */ export interface ConceptRelationship { + concept_id_1: number; + concept_id_2: number; relationship_id: string; relationship_name?: string; - direction?: 'forward' | 'reverse'; - target_concept_id: number; - target_concept_name: string; - target_vocabulary_id: string; - target_concept_code: string; - target_domain_id?: string; - target_concept_class_id?: string; - target_standard_concept?: 'S' | 'C' | 'N' | null; + reverse_relationship_id?: string; + concept_1?: RelationshipConcept; + concept_2?: RelationshipConcept; + valid_start_date?: string; + valid_end_date?: string; invalid_reason?: string | null; } diff --git a/src/concepts/interfaces/index.ts b/src/concepts/interfaces/index.ts index 462a383..2a22877 100644 --- a/src/concepts/interfaces/index.ts +++ b/src/concepts/interfaces/index.ts @@ -12,6 +12,7 @@ export type { RecommendedConceptsResult, RelatedConcept, RelatedConceptsResult, + RelationshipConcept, Synonym, } from './concept.js'; export type { ConceptRelationshipsOptions } from './concept-relationships-options.js'; diff --git a/src/fhir/fhir.ts b/src/fhir/fhir.ts index 2080945..5fa3d11 100644 --- a/src/fhir/fhir.ts +++ b/src/fhir/fhir.ts @@ -44,10 +44,13 @@ export class Fhir { typeof options.coding.code === 'string' && options.coding.code.length > 0; const hasFlatCode = typeof options.code === 'string' && options.code.length > 0; - if (!hasCodingObj && !hasFlatCode) { + // The server also accepts text-only input — `display` alone triggers + // a semantic-search fallback to the best-matching standard concept. + const hasDisplayOnly = typeof options.display === 'string' && options.display.length > 0; + if (!hasCodingObj && !hasFlatCode && !hasDisplayOnly) { return syntheticError( 'missing_required_field', - 'Provide a `coding` object with a `code`, or supply `code` (with optional `system`) at the top level.', + 'Provide a `coding` object with a `code`, a flat `code`, or `display` text for semantic fallback.', ); } diff --git a/src/fhir/interfaces/fhir.ts b/src/fhir/interfaces/fhir.ts index 7a6122b..2fd7f0e 100644 --- a/src/fhir/interfaces/fhir.ts +++ b/src/fhir/interfaces/fhir.ts @@ -74,7 +74,13 @@ export interface FhirBatchResult { export interface FhirCodeableConceptResult { input: Record; - best_match?: FhirResolution; - alternatives: FhirResolution[]; + /** + * The best-matching coding resolved to a standard concept. Wrapped as + * `{ input, resolution }` — the same shape `fhir.resolve()` returns — + * because the server reports both the original coding and the resolution + * details per-pick. + */ + best_match?: FhirResolveResult; + alternatives: FhirResolveResult[]; unresolved: Record[]; } diff --git a/src/index.ts b/src/index.ts index ff753ae..c8749d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export type { RelatedConcept, RelatedConceptsOptions, RelatedConceptsResult, + RelationshipConcept, SuggestConceptsOptions, Synonym, } from './concepts/interfaces/index.js'; diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts index 882b8c7..0563d1e 100644 --- a/src/mappings/interfaces/mapping.ts +++ b/src/mappings/interfaces/mapping.ts @@ -13,19 +13,30 @@ export interface MappingContext { scope?: string; } +/** + * Single mapping row returned by `mappings.get` / `mappings.map`. + * + * Minimum live-API shape: `{ source_concept_id, source_concept_name, + * target_concept_id, target_concept_name, relationship_id, confidence }`. + * The remaining `source_*` / `target_*` metadata fields are populated + * only when the server has them expanded (e.g. when `targetVocabulary` + * was supplied). + */ export interface Mapping { source_concept_id: number; source_concept_name: string; - source_vocabulary_id: string; - source_concept_code: string; + source_vocabulary_id?: string; + source_concept_code?: string; target_concept_id: number; target_concept_name: string; - target_vocabulary_id: string; - target_concept_code: string; + target_vocabulary_id?: string; + target_concept_code?: string; target_domain_id?: string; target_concept_class_id?: string; - mapping_type: string; - relationship_id?: string; + relationship_id: string; + /** Server-side mapping-quality score in `[0, 1]`. */ + confidence?: number; + mapping_type?: string; invalid_reason?: string | null; quality?: MappingQuality; context?: MappingContext; diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..126df32 --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "examples/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "e2e"] +} diff --git a/tsconfig.json b/tsconfig.json index 0878ac6..6512183 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], + "types": ["node"], "outDir": "dist", "rootDir": "src", "strict": true, From 1422115da85e38abab36f37b00602883467729f7 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 12:08:38 +0100 Subject: [PATCH 09/16] Update package.json to include exports field for module resolution and add CHANGELOG.md to files list. --- package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 89de8bc..2039b41 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,16 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ - "dist" + "dist", + "CHANGELOG.md" ], "engines": { "node": ">=22" From fb4031b32028294d23f53384a45a051d41b3d741 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 12:50:36 +0100 Subject: [PATCH 10/16] Update CI workflow to include 'develop' branch for push and pull request triggers. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f9d197..f5c1dbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: test: From 1e570ac888eb951e7477253cdf4c06a8dac59269 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 12:50:49 +0100 Subject: [PATCH 11/16] Update CHANGELOG for version 1.0.0 release, enhance FHIR resolver to accept top-level and nested display fields for semantic fallback, and refine mapping interface documentation. Add tests for new FHIR resolver functionality. --- CHANGELOG.md | 8 ++++++-- examples/search-concepts.ts | 3 ++- src/fhir/fhir.ts | 16 +++++++++++++--- src/mappings/interfaces/mapping.ts | 8 ++++---- test/fhir/fhir.test.ts | 18 ++++++++++++++++++ 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a747f..4e38742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.0.0 +## [Unreleased] + +## [1.0.0] - 2026-05-31 ### Added @@ -61,5 +63,7 @@ client.fhir - resolve, resolveBatch, resolveCodeableConcept Standalone: omophubFhirUrl, getApiKey, setApiKey, hasApiKey ``` - + +[Unreleased]: https://github.com/OMOPHub/omophub-node/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/OMOPHub/omophub-node/releases/tag/v1.0.0 diff --git a/examples/search-concepts.ts b/examples/search-concepts.ts index d87fab4..5f107d4 100644 --- a/examples/search-concepts.ts +++ b/examples/search-concepts.ts @@ -61,8 +61,9 @@ async function bulkLexicalSearch(client: OMOPHub): Promise { ], { defaults: { vocabulary_ids: ['SNOMED', 'RxNorm'], page_size: 3 } }, ); + if (r2.error) throw new Error(r2.error.message); console.log('\n Per-query domain overrides:'); - for (const item of r2.data ?? []) { + for (const item of r2.data) { console.log(` ${item.search_id}:`); for (const c of item.results.slice(0, 3)) { console.log(` ${c.concept_name} (${c.vocabulary_id}/${c.domain_id})`); diff --git a/src/fhir/fhir.ts b/src/fhir/fhir.ts index 5fa3d11..b849d08 100644 --- a/src/fhir/fhir.ts +++ b/src/fhir/fhir.ts @@ -44,10 +44,20 @@ export class Fhir { typeof options.coding.code === 'string' && options.coding.code.length > 0; const hasFlatCode = typeof options.code === 'string' && options.code.length > 0; - // The server also accepts text-only input — `display` alone triggers + // The server also accepts text-only input — display text alone triggers // a semantic-search fallback to the best-matching standard concept. - const hasDisplayOnly = typeof options.display === 'string' && options.display.length > 0; - if (!hasCodingObj && !hasFlatCode && !hasDisplayOnly) { + // Display can sit at the top level (`{ display }`) or nested inside a + // coding object (`{ coding: { display } }`); both are legal FHIR. + const flatDisplay = typeof options.display === 'string' && options.display.length > 0; + const codingDisplay = + options.coding !== undefined && + options.coding !== null && + typeof options.coding === 'object' && + !Array.isArray(options.coding) && + typeof options.coding.display === 'string' && + options.coding.display.length > 0; + const hasDisplay = flatDisplay || codingDisplay; + if (!hasCodingObj && !hasFlatCode && !hasDisplay) { return syntheticError( 'missing_required_field', 'Provide a `coding` object with a `code`, a flat `code`, or `display` text for semantic fallback.', diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts index 0563d1e..90d9e09 100644 --- a/src/mappings/interfaces/mapping.ts +++ b/src/mappings/interfaces/mapping.ts @@ -17,10 +17,10 @@ export interface MappingContext { * Single mapping row returned by `mappings.get` / `mappings.map`. * * Minimum live-API shape: `{ source_concept_id, source_concept_name, - * target_concept_id, target_concept_name, relationship_id, confidence }`. - * The remaining `source_*` / `target_*` metadata fields are populated - * only when the server has them expanded (e.g. when `targetVocabulary` - * was supplied). + * target_concept_id, target_concept_name, relationship_id }`. The + * remaining `source_*` / `target_*` metadata fields and `confidence` + * are populated when the server has them expanded (e.g. when + * `targetVocabulary` was supplied or the row carries a mapping score). */ export interface Mapping { source_concept_id: number; diff --git a/test/fhir/fhir.test.ts b/test/fhir/fhir.test.ts index c1b9692..77c4dd7 100644 --- a/test/fhir/fhir.test.ts +++ b/test/fhir/fhir.test.ts @@ -155,6 +155,24 @@ describe('client.fhir.resolve', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + test('accepts top-level `display` alone for semantic fallback', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ display: 'blood glucose' }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test('accepts nested `coding.display` alone for semantic fallback', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ coding: { display: 'blood glucose' } }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + test('passes onUnmapped through as snake_case', async () => { const fetchMock = createMockFetch(); enqueueSuccess(fetchMock, mockResolution); From bf1419dae3d2ac9cda7670ee7a2481185aa2a127 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 13:52:32 +0100 Subject: [PATCH 12/16] Refactor auth and utility functions for improved error handling and data processing. Trim whitespace in setApiKey before persisting, enhance parseRetryAfter to strictly validate input formats, and update pagination functions to correctly handle outer envelope responses. Add tests for new behaviors and edge cases. --- src/auth/auth.ts | 7 +++- src/common/utils/backoff.ts | 24 +++++++++---- src/common/utils/paginate.ts | 14 ++++++-- src/common/utils/to-snake-case.ts | 6 +++- test/auth/auth.test.ts | 8 +++++ test/common/utils/backoff.test.ts | 22 ++++++++++++ test/common/utils/paginate.test.ts | 45 +++++++++++++++++++++++++ test/common/utils/to-snake-case.test.ts | 12 +++++++ 8 files changed, 127 insertions(+), 11 deletions(-) diff --git a/src/auth/auth.ts b/src/auth/auth.ts index a0883e1..4c38761 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -27,12 +27,17 @@ export function setApiKey(key: string): void { if (typeof key !== 'string' || key.trim().length === 0) { throw new OMOPHubError('setApiKey requires a non-empty key string.'); } + // Trim surrounding whitespace before persisting. We validated with + // `.trim()` above; storing the raw value would leak the whitespace into + // every outbound request's Authorization header and the server would + // reject the key as invalid. + const trimmed = key.trim(); // Direct env assignment can throw `TypeError` in runtimes where // process.env is frozen / sealed / read-only-proxied (some edge // runtimes, restricted Bun modes, etc.). Convert to `OMOPHubError` so // callers can rely on the documented exception type. try { - process.env[ENV_VAR] = key; + process.env[ENV_VAR] = trimmed; } catch (err) { throw new OMOPHubError( `setApiKey could not write to process.env: ${err instanceof Error ? err.message : String(err)}`, diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts index d74585d..41cb045 100644 --- a/src/common/utils/backoff.ts +++ b/src/common/utils/backoff.ts @@ -34,14 +34,26 @@ export function backoffMs(attempt: number, retryAfter: string | null): number { * Parses a `Retry-After` header value (delta-seconds or HTTP-date) into * a number of seconds. Returns `null` for unparseable input. Exported so * `parse-error.ts` can populate `ErrorResponse.retryAfter` consistently. + * + * Strict per RFC 9110 §10.2.3: + * - `delta-seconds` is `1*DIGIT` — no signs, decimals, exponents, or + * whitespace. `Number('')` quietly returns 0; `Number('1.5')` returns + * 1.5; both would otherwise be honoured as retry delays. We reject both. + * - `HTTP-date` is `IMF-fixdate`, `rfc850-date`, or `asctime-date`. All + * three start with a 3-letter weekday prefix; `Date.parse` accepts + * much more (e.g. `'1 hour ago'`, `'2024-01-01'`), so we gate the call + * behind a prefix check. */ export function parseRetryAfter(header: string): number | null { - const seconds = Number(header); - if (Number.isFinite(seconds) && seconds >= 0) return seconds; - const date = Date.parse(header); - if (Number.isFinite(date)) { - const diffMs = date - Date.now(); - return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; + if (/^\d+$/.test(header)) { + return Number(header); + } + if (/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(header)) { + const date = Date.parse(header); + if (Number.isFinite(date)) { + const diffMs = date - Date.now(); + return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; + } } return null; } diff --git a/src/common/utils/paginate.ts b/src/common/utils/paginate.ts index 36564e7..df651fb 100644 --- a/src/common/utils/paginate.ts +++ b/src/common/utils/paginate.ts @@ -55,7 +55,7 @@ export async function* paginate( for (const item of items) yield item; pagesYielded++; - if (!hasNextPage(response.data)) return; + if (!hasNextPage(response)) return; page++; } } @@ -87,7 +87,7 @@ export async function paginateAll( } const items = extractItems(response.data); collected.push(...items); - if (items.length === 0 || !hasNextPage(response.data)) { + if (items.length === 0 || !hasNextPage(response)) { return { data: collected, errors, pagesFetched }; } page++; @@ -101,7 +101,15 @@ function extractItems(data: PaginatedData | T[] | null): T[] { return data.data; } -function hasNextPage(data: PaginatedData | T[] | null): boolean { +function hasNextPage(response: OMOPHubResponse | T[]>): boolean { + // Canonical location per SDK convention — `meta.pagination` always lives + // on the outer envelope. Resource methods that pre-wrap their data into + // a `PaginatedData` shape are handled by the fallback below. + const outerHasNext = response.meta?.pagination?.has_next; + if (outerHasNext === true) return true; + if (outerHasNext === false) return false; + // Fallback: data-internal pagination (legacy / pre-wrapped callers). + const data = response.data; if (!data || Array.isArray(data)) return false; return data.meta.pagination.has_next === true; } diff --git a/src/common/utils/to-snake-case.ts b/src/common/utils/to-snake-case.ts index bcda77c..0f6e32f 100644 --- a/src/common/utils/to-snake-case.ts +++ b/src/common/utils/to-snake-case.ts @@ -29,7 +29,11 @@ export function toSnakeCaseKeys(input: T): T { return input.map((item) => toSnakeCaseKeys(item)) as unknown as T; } if (isPlainObject(input)) { - const out: Record = {}; + // Null-prototype accumulator: prevents `__proto__` (and `constructor`, + // `prototype`) keys in the source from mutating the result's prototype. + // Plain `{}` would trigger Object.prototype's `__proto__` setter on + // bracket assignment. + const out: Record = Object.create(null); for (const [k, v] of Object.entries(input)) { out[camelToSnakeCase(k)] = toSnakeCaseKeys(v); } diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts index a6b7d46..8a76e5e 100644 --- a/test/auth/auth.test.ts +++ b/test/auth/auth.test.ts @@ -47,6 +47,14 @@ describe('auth helpers', () => { expect(process.env.OMOPHUB_API_KEY).toBeUndefined(); }); + test('setApiKey strips surrounding whitespace before persisting', () => { + // A leading newline or trailing space slipped from a `.env` parser or + // copy-paste must not leak into the Authorization header. + setApiKey(' oh_padded_value\n'); + expect(process.env.OMOPHUB_API_KEY).toBe('oh_padded_value'); + expect(getApiKey()).toBe('oh_padded_value'); + }); + test('setApiKey re-throws read-only env errors as OMOPHubError', async () => { const { OMOPHubError } = await import('../../src/errors.js'); // Node's `process.env` is a Proxy that rejects accessor descriptors, diff --git a/test/common/utils/backoff.test.ts b/test/common/utils/backoff.test.ts index b93f3f1..bf3a33a 100644 --- a/test/common/utils/backoff.test.ts +++ b/test/common/utils/backoff.test.ts @@ -65,4 +65,26 @@ describe('backoffMs', () => { expect(result).toBeGreaterThanOrEqual(4_000); expect(result).toBeLessThanOrEqual(6_000); }); + + test('rejects malformed delta-seconds (whitespace, decimals, signs, empty)', () => { + // Each falls back to exponential — the canonical "value not parseable" + // signal. Without strict parsing, `Number('')` would silently return 0 + // and we'd retry instantly with no backoff. + expect(backoffMs(0, '')).toBe(500); + expect(backoffMs(0, ' 5 ')).toBe(500); + expect(backoffMs(0, '1.5')).toBe(500); + expect(backoffMs(0, '+5')).toBe(500); + expect(backoffMs(0, '5e3')).toBe(500); + expect(backoffMs(0, '-5')).toBe(500); + }); + + test('rejects non-HTTP-date strings that Date.parse would otherwise accept', () => { + // `Date.parse('2024-01-01')` succeeds but ISO dates are not valid + // Retry-After values per RFC 9110 (which requires IMF-fixdate / + // rfc850-date / asctime-date). Same for various permissive inputs. + expect(backoffMs(0, '2024-01-01')).toBe(500); + expect(backoffMs(0, '2024-01-01T00:00:00Z')).toBe(500); + expect(backoffMs(0, 'tomorrow')).toBe(500); + expect(backoffMs(0, '1 hour')).toBe(500); + }); }); diff --git a/test/common/utils/paginate.test.ts b/test/common/utils/paginate.test.ts index be27d16..8521f80 100644 --- a/test/common/utils/paginate.test.ts +++ b/test/common/utils/paginate.test.ts @@ -119,3 +119,48 @@ describe('paginateAll (eager collect)', () => { expect(result.pagesFetched).toBe(2); }); }); + +describe('hasNextPage — outer envelope pagination', () => { + // Some endpoints return a bare `T[]` as `response.data` and carry + // pagination on the outer `response.meta.pagination` (the canonical + // location per SDK convention). The pagination helper must honour that + // — earlier behaviour hard-coded arrays as non-paginated. + function bareArrayPage(items: T[], hasNext: boolean, page = 1, pageSize = 100) { + return Promise.resolve({ + data: items, + error: null, + meta: { + pagination: { + page, + page_size: pageSize, + total_items: hasNext ? page * pageSize + 1 : items.length, + total_pages: hasNext ? page + 1 : page, + has_next: hasNext, + has_previous: page > 1, + }, + }, + headers: {}, + } as unknown as OMOPHubResponse); + } + + test('paginate walks pages when data is bare array + outer meta says has_next', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => bareArrayPage([1, 2], true, 1, s)) + .mockImplementationOnce((_p, s) => bareArrayPage([3, 4], false, 2, s)); + const collected: number[] = []; + for await (const n of paginate(fetchPage, { pageSize: 2 })) collected.push(n); + expect(collected).toEqual([1, 2, 3, 4]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('paginateAll walks pages when data is bare array + outer meta says has_next', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => bareArrayPage(['a', 'b'], true, 1, s)) + .mockImplementationOnce((_p, s) => bareArrayPage(['c'], false, 2, s)); + const result = await paginateAll(fetchPage, { pageSize: 2 }); + expect(result.data).toEqual(['a', 'b', 'c']); + expect(result.pagesFetched).toBe(2); + }); +}); diff --git a/test/common/utils/to-snake-case.test.ts b/test/common/utils/to-snake-case.test.ts index 15505ba..af45728 100644 --- a/test/common/utils/to-snake-case.test.ts +++ b/test/common/utils/to-snake-case.test.ts @@ -85,4 +85,16 @@ describe('toSnakeCaseKeys', () => { expect(out.source_coding).toBe(c); expect(out.source_coding).toBeInstanceOf(Coding); }); + + test('does not let a `__proto__` key mutate the result prototype', () => { + // `JSON.parse` produces an own `__proto__` property (vs. an object + // literal, which would set [[Prototype]]). A naive `out['__proto__'] = v` + // would invoke Object.prototype's setter and replace the result's + // prototype with `v`. + const malicious = JSON.parse('{"__proto__":{"polluted":true}}'); + const out = toSnakeCaseKeys(malicious) as Record; + expect((out as { polluted?: unknown }).polluted).toBeUndefined(); + // Object.prototype must remain unaffected globally either way. + expect(({} as { polluted?: unknown }).polluted).toBeUndefined(); + }); }); From c6ca3fafdbde5f5d1bc94b6ee270fe5f3ae4243e Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 14:11:50 +0100 Subject: [PATCH 13/16] Enhance `parseRetryAfter` function to strictly validate HTTP-date formats by implementing full regex checks for `IMF-fixdate`, `rfc850-date`, and `asctime-date`. Update tests to cover rejection of malformed dates and acceptance of valid formats, ensuring compliance with RFC 9110 specifications. --- src/common/utils/backoff.ts | 42 +++++++++++++++++++++++++------ test/common/utils/backoff.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts index 41cb045..95ed380 100644 --- a/src/common/utils/backoff.ts +++ b/src/common/utils/backoff.ts @@ -30,26 +30,52 @@ export function backoffMs(attempt: number, retryAfter: string | null): number { return exp * (1 - 0.25 * Math.random()); } +// Per RFC 9110 §5.6.7. `Date.parse` itself accepts a much broader grammar +// (ISO strings, trailing-junk strict-looking dates, natural language in +// some engines), so we full-string match before trusting it. +const IMF_FIXDATE_RE = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/; +const RFC850_DATE_RE = + /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT$/; +const ASCTIME_DATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/; + /** * Parses a `Retry-After` header value (delta-seconds or HTTP-date) into * a number of seconds. Returns `null` for unparseable input. Exported so * `parse-error.ts` can populate `ErrorResponse.retryAfter` consistently. * - * Strict per RFC 9110 §10.2.3: + * Strict per RFC 9110 §10.2.3 / §5.6.7: * - `delta-seconds` is `1*DIGIT` — no signs, decimals, exponents, or * whitespace. `Number('')` quietly returns 0; `Number('1.5')` returns - * 1.5; both would otherwise be honoured as retry delays. We reject both. - * - `HTTP-date` is `IMF-fixdate`, `rfc850-date`, or `asctime-date`. All - * three start with a 3-letter weekday prefix; `Date.parse` accepts - * much more (e.g. `'1 hour ago'`, `'2024-01-01'`), so we gate the call - * behind a prefix check. + * 1.5; both would otherwise be honoured as retry delays. + * - `HTTP-date` is exactly one of `IMF-fixdate` (preferred), + * `rfc850-date`, or `asctime-date`. Each is matched against a full + * regex before handing off to `Date.parse`. `asctime-date` has no + * timezone field; `Date.parse` interprets the wall-clock as local + * time (the original C `asctime()` convention), but RFC 9110 expects + * UTC, so we normalise the matched components into an IMF-fixdate + * ending in `GMT` before parsing. */ export function parseRetryAfter(header: string): number | null { if (/^\d+$/.test(header)) { return Number(header); } - if (/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(header)) { - const date = Date.parse(header); + let toParse: string | null = null; + if (IMF_FIXDATE_RE.test(header) || RFC850_DATE_RE.test(header)) { + toParse = header; + } else { + const m = ASCTIME_DATE_RE.exec(header); + if (m) { + const [, dayName, month, day, time, year] = m; + // Group 3 is `( 2DIGIT / ( SP 1DIGIT ))` — single-digit days arrive + // as " 6" (space-prefixed); IMF-fixdate requires zero-padding. + const dd = (day ?? '').trim().padStart(2, '0'); + toParse = `${dayName}, ${dd} ${month} ${year} ${time} GMT`; + } + } + if (toParse !== null) { + const date = Date.parse(toParse); if (Number.isFinite(date)) { const diffMs = date - Date.now(); return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; diff --git a/test/common/utils/backoff.test.ts b/test/common/utils/backoff.test.ts index bf3a33a..f1919fb 100644 --- a/test/common/utils/backoff.test.ts +++ b/test/common/utils/backoff.test.ts @@ -87,4 +87,46 @@ describe('backoffMs', () => { expect(backoffMs(0, 'tomorrow')).toBe(500); expect(backoffMs(0, '1 hour')).toBe(500); }); + + test('rejects malformed dates that merely start with an HTTP-date weekday prefix', () => { + // V8's `Date.parse` will accept a strict-looking date with trailing + // junk and return a finite timestamp. The full-string regex match + // rejects these. + expect(backoffMs(0, 'Sunday is a holiday')).toBe(500); + expect(backoffMs(0, 'Mon Nov 6 12:00:00 1994 garbage')).toBe(500); + expect(backoffMs(0, 'Sun, 06 Nov 1994 08:49:37 GMT extra')).toBe(500); + expect(backoffMs(0, 'Sun, 06 Nov 1994 08:49:37 PST')).toBe(500); // wrong zone + expect(backoffMs(0, 'Sun, 6 Nov 1994 08:49:37 GMT')).toBe(500); // 1-digit day + }); + + test('accepts canonical rfc850-date and asctime-date HTTP-date formats', () => { + // Construct the same instant as the IMF-fixdate test, in each of the + // two obsolete forms RFC 9110 still requires us to honour. + const future = new Date(Date.now() + 5_000); + const yy = String(future.getUTCFullYear() % 100).padStart(2, '0'); + const yyyy = String(future.getUTCFullYear()); + const monthAbbr = future.toUTCString().slice(8, 11); + const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][future.getUTCDay()]; + const fullDayName = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ][future.getUTCDay()]; + const dd = String(future.getUTCDate()).padStart(2, '0'); + const HH = String(future.getUTCHours()).padStart(2, '0'); + const MM = String(future.getUTCMinutes()).padStart(2, '0'); + const SS = String(future.getUTCSeconds()).padStart(2, '0'); + + const rfc850 = `${fullDayName}, ${dd}-${monthAbbr}-${yy} ${HH}:${MM}:${SS} GMT`; + const asctime = `${dayName} ${monthAbbr} ${dd} ${HH}:${MM}:${SS} ${yyyy}`; + + expect(backoffMs(0, rfc850)).toBeGreaterThanOrEqual(4_000); + expect(backoffMs(0, rfc850)).toBeLessThanOrEqual(6_000); + expect(backoffMs(0, asctime)).toBeGreaterThanOrEqual(4_000); + expect(backoffMs(0, asctime)).toBeLessThanOrEqual(6_000); + }); }); From 4c52b251d447a5211d5a2304718a723d05f7d5d2 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 14:18:13 +0100 Subject: [PATCH 14/16] Enhance CI workflow by adding manual triggering capability and introducing a new integration job for end-to-end tests. The integration job runs on pushes to main and develop branches, ensuring secure testing against the live API while avoiding exposure of sensitive keys in pull requests. --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c1dbd..3ccff3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main, develop] pull_request: branches: [main, develop] + workflow_dispatch: # Allow manual triggering jobs: test: @@ -25,3 +26,22 @@ jobs: - run: npm run lint - run: npm run typecheck - run: npm test + + integration: + # Only run on push to main/develop, not on PRs (avoids leaking the + # live API key to forked-PR runners and keeps PR CI fast). + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run build + - name: Run e2e tests against live API + env: + OMOPHUB_API_KEY: ${{ secrets.TEST_API_KEY }} + run: npm run test:e2e From 98421645ca06216029d78d6799b7bd7fc16a8079 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 14:21:54 +0100 Subject: [PATCH 15/16] Refine CI workflow to enforce branch restrictions for integration job, ensuring it only runs on pushes or manual dispatches to main and develop branches. This change enhances security by preventing exposure of sensitive API keys during pull request workflows. --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ccff3e..528270e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,16 @@ jobs: - run: npm test integration: - # Only run on push to main/develop, not on PRs (avoids leaking the - # live API key to forked-PR runners and keeps PR CI fast). - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + # Only run on push or manual dispatch *and* only on main/develop. + # The branch check is load-bearing for `workflow_dispatch`: the + # Actions UI lets you pick any ref, so without it a collaborator + # could push a modified workflow to a feature branch and fire it + # with `TEST_API_KEY` attached, bypassing PR review. `push` events + # are already gated by `on.push.branches`, but checking github.ref + # here keeps both event types under one rule. + if: | + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 From f040834aac6fc1525f22d7fc89bf7993720b849b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sun, 31 May 2026 14:29:56 +0100 Subject: [PATCH 16/16] Implement retry logic for POST requests on 429 status codes by introducing `isNoSideEffectStatus` function. Update `isRetryableRequest` to allow retries for non-idempotent requests when the server declines without processing. Enhance tests to verify behavior for 429 responses. --- src/client.ts | 8 +++++--- src/common/utils/backoff.ts | 14 ++++++++++++++ test/client-http.test.ts | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 196e30a..9187115 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ import type { PatchOptions } from './common/interfaces/patch-options.js'; import type { HeadersInit, PerCallOptions } from './common/interfaces/per-call-options.js'; import type { PostOptions } from './common/interfaces/post-options.js'; import type { PutOptions } from './common/interfaces/put-options.js'; -import { backoffMs, isRetryableStatus } from './common/utils/backoff.js'; +import { backoffMs, isNoSideEffectStatus, isRetryableStatus } from './common/utils/backoff.js'; import { appendQuery, buildQuery } from './common/utils/build-query.js'; import { envOrUndefined } from './common/utils/env.js'; import { mergeHeaders } from './common/utils/merge-headers.js'; @@ -197,7 +197,7 @@ export class OMOPHub { if ( isRetryableStatus(response.status) && attempt < this.maxRetries && - isRetryableRequest(method, headers) + (isNoSideEffectStatus(response.status) || isRetryableRequest(method, headers)) ) { const retryAfter = response.headers.get('retry-after'); // Drain the body so undici releases the connection back to the pool @@ -263,7 +263,9 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); * A request is safe to retry when its HTTP verb is idempotent (RFC 7231) * or when the caller has supplied an `Idempotency-Key` header — at which * point the server can deduplicate on its side. POST and PATCH without a - * key are NOT retried, since retry could create duplicates. + * key are NOT retried by this gate, since retry could create duplicates. + * (`isNoSideEffectStatus` bypasses this for statuses like 429 where the + * server explicitly rejected without processing.) */ function isRetryableRequest(method: string, headers: Headers): boolean { if (IDEMPOTENT_METHODS.has(method)) return true; diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts index 95ed380..fc6035b 100644 --- a/src/common/utils/backoff.ts +++ b/src/common/utils/backoff.ts @@ -9,6 +9,20 @@ export function isRetryableStatus(status: number): boolean { return RETRYABLE_STATUSES.has(status); } +/** + * Statuses where the server is guaranteed not to have touched state + * before rejecting (RFC 9110 §15.5.29: 429 means the request was + * declined, not processed). Safe to retry regardless of HTTP method, + * since there's nothing to deduplicate. Distinct from 5xx, where the + * upstream may have partially processed the request before failing — + * those still gate on `Idempotency-Key` to avoid duplicate side effects. + */ +const NO_SIDE_EFFECT_STATUSES = new Set([429]); + +export function isNoSideEffectStatus(status: number): boolean { + return NO_SIDE_EFFECT_STATUSES.has(status); +} + /** * Computes the delay before the next retry attempt. * diff --git a/test/client-http.test.ts b/test/client-http.test.ts index 5e79335..b47e0ed 100644 --- a/test/client-http.test.ts +++ b/test/client-http.test.ts @@ -171,6 +171,25 @@ describe('OMOPHub HTTP dispatch', () => { expect(headers).toBeNull(); }); + test('DOES retry POST without an Idempotency-Key on 429 (pre-processing rejection)', async () => { + // 429 means the server declined before touching state (RFC 9110 + // §15.5.29), so retry can't create duplicates — distinct from 5xx + // where the upstream may have partially processed. The FHIR e2e + // suite is the canonical case: every `fhir.*` endpoint is POST. + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 429, mockApiErrorBody('rate_limit_exceeded', 'slow'), { + 'retry-after': '0', + }); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + test('does NOT retry POST without an Idempotency-Key on 503', async () => { const fetchMock = createMockFetch(); enqueueError(fetchMock, 503);