Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file. The format

## [Unreleased]

## [1.1.0] - 2026-08-11

### Added

- `GetMappingsOptions` extends `PaginationOptions`, so `mappings.get()` accepts `page` and `pageSize`. `GET /v1/concepts/{id}/mappings` became paginated on 2026-08-04; before that it applied a fixed `LIMIT 100` server-side with no total and no `has_next`, so a concept with 1,500 mappings returned 100 of them and nothing said so. `pageSize` defaults to 100 server-side, matching the old cap, so an existing call returns exactly the page it returned before - now with `meta.pagination` alongside it.
- `mappings.getIter()` and `mappings.getAll()`, matching the `basicIter` / `basicAll` pattern on the search resource. Prefer these when assembling a code list: they follow `has_next` to the end rather than trusting one page. `getIter` throws `OMOPHubIteratorError` on a failed page; `getAll` accumulates errors as values so a partial result is distinguishable from a complete one.

- `GetMappingsOptions.relationshipIds` - relationship types to return, comma-joined onto the query string. The server defaults to `['Maps to']`, so a composite concept returns only half its decomposition unless `'Maps to value'` is asked for too: "Allergy to penicillin G" maps to "Allergy to drug" via `Maps to` and to "penicillin G" via `Maps to value`. The parameter existed on the endpoint but was ignored server-side until 2026-08-11, so there was nothing to expose before now.

### Fixed

- `mappings.getIter()` / `getAll()` no longer loop forever against a deployment that omits `meta.pagination`. Such a server predates 2026-08-04, when the query was `LIMIT 100` with **no OFFSET** — it ignores `page` and returns the same first rows for every request. The fallback inferred "a full page means there is more", so a concept with 100+ mappings re-fetched the identical page indefinitely, yielding duplicates and, in `getAll`, growing the array without bound. A response with no pagination metadata is now treated as the whole of what that server will give us.
- `mappings.getIter()` / `getAll()` clamp `pageSize` to the endpoint's 200 ceiling before requesting. The server clamps silently, so asking for 500 and receiving 200 read as a short page — i.e. the end — and truncated the walk on servers that omit pagination metadata.
- `search.semanticIter()` / `semanticAll()` clamp `pageSize` to the endpoint's ceiling of 100. `/v1/search/semantic` validates `page_size` at `max: 100` and **rejects** rather than clamping — unlike every other paginated endpoint, where the router-wide middleware silently clamps to 200. So `semanticAll('diabetes', { pageSize: 200 })` 400'd on its first page and returned zero results with one error, rather than walking with a smaller page. `basicIter` / `basicAll` are deliberately not clamped — that endpoint returns a truthful `meta.pagination`, so a server-side clamp only yields smaller pages and the walk still terminates correctly.

### Changed

- `derivePagination` and `ITER_DEFAULT_PAGE_SIZE` hoisted from `src/search/search.ts` into `src/common/utils/paginate.ts` and shared by both resources, replacing a verbatim copy. What to assume when `meta.pagination` is absent is now an explicit `MissingMetaPolicy` argument rather than a hardcoded guess: search keeps `'more-if-page-full'`, mappings uses `'single-page'`. The two are not interchangeable, and the failure is asymmetric — guessing "there is more" against a server that ignores `page` never terminates, while guessing "that was everything" only stops early.
- `derivePagination`'s `policy` argument is **required**, not defaulted. A default hands out `'more-if-page-full'` — the option that never terminates against a server ignoring `page` — so a new resource would inherit the unsafe behaviour silently, which is how the mappings loop happened. Every call site now states its assumption and the compiler enforces it. Search passes `'more-if-page-full'` explicitly: `/v1/search/concepts` has returned real pagination since the initial commit and `/v1/search/semantic` passes `page` through to the search service.

- `PaginateOptions` moved from `src/search/interfaces/` to `src/common/interfaces/` now that more than one resource iterates. The exported type name and its export from the package root are unchanged; only the internal path moved.

### Notes

- The server clamps `page_size` to 200 on this endpoint and does not report having done so, so a larger value silently yields a smaller page.

## [1.0.3] - 2026-06-13

### Changed
Expand All @@ -20,7 +46,7 @@ All notable changes to this project will be documented in this file. The format

### Changed

- `client.search.semantic()` now calls the canonical path `GET /v1/search/semantic` instead of `GET /v1/concepts/semantic-search`. The legacy path remains a permanent server-side alias, so older SDK installations continue to work — no breaking change. The `User-Agent` and `__version__` are bumped to `1.0.1`.
- `client.search.semantic()` now calls the canonical path `GET /v1/search/semantic` instead of `GET /v1/concepts/semantic-search`. The legacy path remains a permanent server-side alias, so older SDK installations continue to work - no breaking change. The `User-Agent` and `__version__` are bumped to `1.0.1`.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ export async function GET(req: Request) {
| `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()` |
| `mappings` | Cross-vocabulary mappings | `get()`, `getIter()`, `getAll()`, `map()` |
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()`, `domainStats()`, `domains()`, `conceptClasses()`, `concepts()` |
| `domains` | Domain catalog | `list()`, `concepts()` |
| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolveBatch()`, `resolveCodeableConcept()` |
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ npx tsx examples/fhir-resolver.ts
| [`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 |
| [`map-between-vocabularies.ts`](./map-between-vocabularies.ts) | `mappings.get` / `getIter` / `getAll`, `relationshipIds` (Value-as-Concept), `includeInvalid`, `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
Expand Down
155 changes: 145 additions & 10 deletions examples/map-between-vocabularies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,76 @@ async function getMappings(): Promise<void> {
console.log('=== Concept Mappings ===');
const client = new OMOPHub();

// Type 2 diabetes mellitus (SNOMED)
// Type 2 diabetes mellitus (SNOMED, standard)
const conceptId = 201826;
const { data, error } = await client.mappings.get(conceptId, {
targetVocabulary: 'ICD10CM',
});

// `get()` returns ONE page. `meta.pagination` is the only thing that says
// whether it was the whole set — see getEveryMapping() below.
const { data, error } = await client.mappings.get(conceptId);
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}`);

console.log(`Mappings for concept ${conceptId} (this page: ${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}`);
// A mapping row carries only these fields. `target_vocabulary_id` and
// `target_concept_code` are optional on the type because this endpoint
// does not return them — fetch the target concept if you need them.
console.log(` ${m.relationship_id}: ${m.target_concept_id} ${m.target_concept_name}`);
}
}

/**
* Find which ICD-10-CM codes correspond to a SNOMED concept.
*
* Note the DIRECTION. `Maps to` always points at a *standard* concept, and
* ICD-10-CM is non-standard, so `targetVocabulary: 'ICD10CM'` on the default
* relationship matches nothing — it returns an empty list rather than an
* error. The codes that roll up INTO a standard concept need `Mapped from`.
*/
async function mapToSpecificVocabulary(): Promise<void> {
console.log('\n=== Mapping to a Specific Vocabulary ===');
const client = new OMOPHub();

const conceptId = 201826;

// Check `error` before reading the count. A failed request yields no rows,
// which would print as "0 rows (as expected)" — i.e. exactly the conclusion
// this section exists to teach, reached for entirely the wrong reason.
const { data: empty, error: emptyError } = await client.mappings.get(conceptId, {
targetVocabulary: 'ICD10CM',
});
if (emptyError) {
console.log(` 'Maps to' + ICD10CM: request failed — ${emptyError.message}`);
} else {
console.log(` 'Maps to' + ICD10CM: ${empty.mappings.length} rows (as expected)`);
}

// getAll accumulates errors rather than throwing, so a partial walk looks
// like a complete one unless you look.
const icd = await client.mappings.getAll(conceptId, {
relationshipIds: ['Mapped from'],
targetVocabulary: 'ICD10CM',
});
const [icdError] = icd.errors;
if (icdError) {
console.log(` 'Mapped from' + ICD10CM: INCOMPLETE — ${icdError.message}`);
return;
}
console.log(` 'Mapped from' + ICD10CM: ${icd.data.length} rows`);

// The mapping row has the target's id and name but not its code, so resolve
// the first few. One request each — fine for five rows, not for all 74.
for (const m of icd.data.slice(0, 5)) {
const { data: target, error: targetErr } = await client.concepts.get(m.target_concept_id);
if (targetErr) {
console.log(` ← ${m.target_concept_id} (code lookup failed: ${targetErr.message})`);
continue;
}
console.log(
` ← [${target.vocabulary_id}] ${target.concept_code} ${target.concept_name}`,
);
}
}

Expand Down Expand Up @@ -110,8 +163,90 @@ async function lookupByCode(): Promise<void> {
}
}

/**
* Walk every page instead of trusting the first one.
*
* This is the one to copy when you are building a code list: a partial code
* list is wrong in a way nothing in the result reveals.
*/
async function getEveryMapping(): Promise<void> {
console.log('\n=== Every Mapping (all pages) ===');
const client = new OMOPHub();

const conceptId = 201826;

// Eager: one array, errors accumulated rather than thrown, so a partial
// result is distinguishable from a complete one.
const { data, errors, pagesFetched } = await client.mappings.getAll(conceptId);
const [firstError] = errors;
if (firstError) {
// Errors are values here, not exceptions, so a partial result looks
// exactly like a complete one unless you check.
console.log(` Incomplete — ${errors.length} page(s) failed: ${firstError.message}`);
return;
}
console.log(` ${data.length} mappings across ${pagesFetched} page(s)`);

// `getIter` is the streaming half of the same walk — one mapping at a time,
// without holding them all. Use one or the other; running both would page
// the whole set twice. It throws on a failed page rather than accumulating.
//
// for await (const m of client.mappings.getIter(conceptId)) { ... }
}

/**
* Composite concepts decompose across TWO relationships, and the default
* returns only the first — you learn the patient is allergic to *a drug*
* but not *which* drug.
*/
async function valueAsConcept(): Promise<void> {
console.log('\n=== Value-as-Concept ===');
const client = new OMOPHub();

const conceptId = 4167462; // Allergy to penicillin G

const { data, error } = await client.mappings.get(conceptId, {
relationshipIds: ['Maps to', 'Maps to value'],
});
if (error) {
console.log(`API error: ${error.message}`);
return;
}

for (const m of data.mappings) {
// `Maps to` → the OMOP concept column; `Maps to value` → value_as_concept_id.
const column = m.relationship_id === 'Maps to value' ? 'value_as_concept_id' : 'concept_id';
console.log(` ${m.relationship_id}: ${m.target_concept_name} → ${column}`);
}
}

/** Deprecated mappings are returned by default; pass false to drop them. */
async function excludeInvalid(): Promise<void> {
console.log('\n=== Valid Mappings Only ===');
const client = new OMOPHub();

const conceptId = 201826;
const withInvalid = await client.mappings.getAll(conceptId);
const validOnly = await client.mappings.getAll(conceptId, { includeInvalid: false });

// Comparing two counts is only meaningful if both walks completed — a failed
// page would show up as a difference that looks like a filtering effect.
const [failed] = [...withInvalid.errors, ...validOnly.errors];
if (failed) {
console.log(` Cannot compare — a walk failed: ${failed.message}`);
return;
}

console.log(` default (includes deprecated): ${withInvalid.data.length}`);
console.log(` includeInvalid: false: ${validOnly.data.length}`);
}

async function main(): Promise<void> {
await getMappings();
await mapToSpecificVocabulary();
await getEveryMapping();
await valueAsConcept();
await excludeInvalid();
await mapConcepts();
await mapByNativeCode();
await lookupByCode();
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@omophub/omophub-node",
"version": "1.0.3",
"version": "1.1.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",
Expand Down
1 change: 1 addition & 0 deletions src/common/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
} from './api-envelope.js';
export type { DeleteOptions } from './delete-options.js';
export type { GetOptions } from './get-options.js';
export type { PaginateOptions } from './paginate-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';
Expand Down
3 changes: 3 additions & 0 deletions src/common/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export {
normaliseSemanticSearchData,
} from './normalize-search-response.js';
export {
derivePagination,
ITER_DEFAULT_PAGE_SIZE,
type MissingMetaPolicy,
type PageFetcher,
type PaginateAllResult,
paginate,
Expand Down
54 changes: 54 additions & 0 deletions src/common/utils/paginate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,60 @@ export async function paginateAll<T>(
return { data: collected, errors, pagesFetched };
}

/** Default page size for every resource's `*Iter` / `*All` helper. */
export const ITER_DEFAULT_PAGE_SIZE = 100;

/**
* What to assume when a response carries no `meta.pagination`.
*
* - `'more-if-page-full'` — the endpoint really does paginate, it just does
* not report it, so a full page implies another one. Safe only when the
* server is known to honour `page`. Note that every current search endpoint
* *does* return `meta.pagination` (measured 2026-08-11), so for those this
* is a fallback for older deployments rather than the normal path.
* - `'single-page'` — treat the response as the whole of what this server
* will give us. Correct when a missing meta block means the deployment
* predates pagination on that endpoint, because such a server ignores
* `page` and returns the same first rows forever.
*/
export type MissingMetaPolicy = 'more-if-page-full' | 'single-page';

/**
* Best-effort pagination metadata for a response that lacks its own.
*
* When `meta.pagination` is present it is returned untouched — the server's
* own `has_next` always wins. The `policy` only decides what to do in its
* absence, and getting that wrong is not symmetric: guessing "there is more"
* against a server that ignores `page` re-fetches the same rows forever,
* whereas guessing "that was everything" merely stops early.
*
* `policy` is deliberately REQUIRED rather than defaulted. The unsafe option
* is the one a default would hand out, so a new resource must state which
* assumption it is making about its endpoint instead of inheriting one — the
* mappings iterators looped forever precisely because that assumption was
* implicit.
*/
export function derivePagination(
response: OMOPHubResponse<unknown>,
page: number,
pageSize: number,
actualCount: number,
policy: MissingMetaPolicy,
) {
const fromMeta = response.meta?.pagination;
if (fromMeta) return fromMeta;

const hasNext = policy === 'single-page' ? false : actualCount >= pageSize;
return {
page,
page_size: pageSize,
total_items: actualCount,
total_pages: hasNext ? page + 1 : page,
has_next: hasNext,
has_previous: page > 1,
};
}

function extractItems<T>(data: PaginatedData<T> | T[] | null): T[] {
if (data === null) return [];
if (Array.isArray(data)) return data;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
GetOptions,
IdempotentRequest,
PaginatedData,
PaginateOptions,
PaginationMeta,
PaginationOptions,
PatchOptions,
Expand Down Expand Up @@ -121,7 +122,6 @@ export type {
BulkSemanticResultItem,
BulkSemanticSearchInput,
BulkSemanticSearchResponse,
PaginateOptions,
RelationshipFilter,
SearchFacet,
SearchFacets,
Expand Down
19 changes: 18 additions & 1 deletion src/mappings/interfaces/get-mappings-options.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import type { PaginationOptions } from '../../common/interfaces/pagination.js';
import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js';

export interface GetMappingsOptions extends VocabReleaseMixin {
export interface GetMappingsOptions extends PaginationOptions, VocabReleaseMixin {
/** Restrict mappings to a single target vocabulary (e.g. `'SNOMED'`). */
targetVocabulary?: string;
/**
* Relationship types to return. Comma-joined onto the query string; the
* server defaults to `['Maps to']`.
*
* Pass `['Maps to', 'Maps to value']` to also get the Value-as-Concept
* decomposition of composite concepts — "Allergy to penicillin G" maps to
* "Allergy to drug" via `Maps to` and to "penicillin G" via
* `Maps to value`, and the default returns only the first of those.
*/
relationshipIds?: string[];
/**
* Whether to return mappings whose relationship or target concept is
* deprecated. Omit to take the server default, which for this endpoint is
* to **include** them; pass `false` to exclude them. The source concept is
* never filtered, so a deprecated concept still returns what it maps to.
*/
includeInvalid?: boolean;
}
Loading
Loading