From d8b344b35feee1196f7d48d6e0069bf299a03c8a Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 16:09:18 -0600 Subject: [PATCH 1/3] resolveCode: build one provider per code system per request, and await locate() resolveCode ran factory.build() on every call. A page calls it once per code it shows, and for a database-backed code system (RxNorm, LOINC, ...) each build opens a SQLite connection that is registered on the request and stays open until the request ends. The HTML of a value set listing 33,341 RxNorm codes opened 33,341 connections and about 2 GB outside the JS heap. The provider is now built once per factory (system and version) per operation, registered once, and reused. The build promise is kept, so overlapping calls share it; a failed build is not kept. It also called csp.locate(code) and csp.display(c) without await, so c was a Promise: any code "resolved", and the description was a Promise. locate() is awaited and its context used, and display() is awaited. Co-Authored-By: Claude Opus 5.5 --- tests/tx/provider-resolve-code.test.js | 132 +++++++++++++++++++++++++ tx/provider.js | 48 ++++++++- 2 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 tests/tx/provider-resolve-code.test.js diff --git a/tests/tx/provider-resolve-code.test.js b/tests/tx/provider-resolve-code.test.js new file mode 100644 index 00000000..add28527 --- /dev/null +++ b/tests/tx/provider-resolve-code.test.js @@ -0,0 +1,132 @@ +/** + * resolveCode is asked about every code a page shows, so it must not build the code + * system once per code: for a database-backed code system each build opens a connection + * that stays open until the request ends. It builds one provider per code system and + * version in an operation, and reuses it. + * + * It also has to wait for locate(), which is async, so that what a code resolves to is + * the concept that was found, with its display. + */ + +const { Provider } = require('../../tx/provider'); +const { OperationContext } = require('../../tx/operation-context'); +const { Languages } = require('../../library/languages'); + +const SYSTEM = 'http://example.org/states'; +const CODES = { AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona' }; + +/** A factory whose providers find CODES, counting how many it builds. */ +function fakeFactory({ iteratable = true, codeLink = () => undefined, id = 'states' } = {}) { + const factory = { + builds: 0, + closed: 0, + id: () => id, + iteratable: () => iteratable, + codeLink, + async build() { + factory.builds++; + return { + async locate(code) { + return CODES[code] ? { context: { code, display: CODES[code] }, message: null } : { context: null, message: 'not found' }; + }, + async display(context) { + return context.display; + }, + async close() { + factory.closed++; + }, + }; + }, + }; + return factory; +} + +function providerWith(factories) { + // Building a real Provider means building a library; resolveCode needs only these. + const provider = Object.create(Provider.prototype); + provider.path = '/tx/r4'; + provider.codeSystemFactories = new Map(Object.entries(factories)); + provider.codeSystems = new Map(); + return provider; +} + +const newContext = () => new OperationContext(new Languages(null)); + +describe('resolveCode', () => { + test('builds the provider once for many codes of one system', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + for (let i = 0; i < 50; i++) { + for (const code of Object.keys(CODES)) { + await provider.resolveCode(opContext, SYSTEM, null, code); + } + } + expect(factory.builds).toBe(1); + // ...and registers it once, so it is closed once when the operation ends. + await opContext.closeProviders(); + expect(factory.closed).toBe(1); + }); + + test('calls that overlap share one build', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + const results = await Promise.all(Object.keys(CODES).map((code) => provider.resolveCode(opContext, SYSTEM, null, code))); + expect(factory.builds).toBe(1); + expect(results.map((r) => r.description)).toEqual(Object.values(CODES)); + }); + + test('builds once per system and version, and again for another operation', async () => { + const v1 = fakeFactory(); + const v2 = fakeFactory(); + const provider = providerWith({ [SYSTEM + '|1']: v1, [SYSTEM + '|2']: v2 }); + const a = newContext(); + await provider.resolveCode(a, SYSTEM, '1', 'AL'); + await provider.resolveCode(a, SYSTEM + '|1', null, 'AK'); + await provider.resolveCode(a, SYSTEM, '2', 'AL'); + expect([v1.builds, v2.builds]).toEqual([1, 1]); + await provider.resolveCode(newContext(), SYSTEM, '1', 'AL'); + expect(v1.builds).toBe(2); + }); + + test("waits for locate(), so a code comes back with the concept's display", async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'AK')) + .toEqual({ link: '/tx/r4/CodeSystem/x-states', description: 'Alaska' }); + }); + + test('a code the code system does not have resolves to nothing', async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'XX')).toBeNull(); + }); + + test("a factory's codeLink is given the concept that was found", async () => { + const seen = []; + const factory = fakeFactory({ + iteratable: false, + codeLink: (c) => { seen.push(c); return 'https://example.org/browse/' + c.code; }, + }); + const provider = providerWith({ [SYSTEM]: factory }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'AZ')) + .toEqual({ link: 'https://example.org/browse/AZ', description: 'Arizona' }); + expect(seen).toEqual([{ code: 'AZ', display: 'Arizona' }]); + }); + + test('a build that fails is tried again on the next call', async () => { + const factory = fakeFactory(); + const build = factory.build; + let fail = true; + factory.build = async (...args) => { + if (fail) { + fail = false; + throw new Error('no database'); + } + return build(...args); + }; + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + await expect(provider.resolveCode(opContext, SYSTEM, null, 'AL')).rejects.toThrow('no database'); + expect((await provider.resolveCode(opContext, SYSTEM, null, 'AL')).description).toBe('Alabama'); + }); +}); diff --git a/tx/provider.js b/tx/provider.js index d5ebef2e..bdf70879 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -13,6 +13,15 @@ const ValueSet = require("./library/valueset"); const {PackageConceptMapProvider} = require("./cm/cm-package"); const { applyIdPrefix, CORE_PREFIX } = require("./library/resource-ids"); +/** + * The code system providers resolveCode() has built, per operation: opContext -> + * Map(factory -> Promise of the provider). A page asks resolveCode about every code it + * shows, and for a database-backed code system each build opens a connection that stays + * open until the request ends, so one is built per code system and version, and reused. + * Keyed weakly, so the entry goes when the operation does. + */ +const resolveCodeProviders = new WeakMap(); + /** * This class holds what information is in context * @@ -477,21 +486,21 @@ class Provider { factory = this.codeSystemFactories.get(vurlMM); } if (factory != null) { - const csp = await factory.build(opContext, []); - opContext.registerProvider(csp); - const c = csp ? csp.locate(code) : null; + const csp = await this.resolveCodeProvider(opContext, factory); + const located = csp ? await csp.locate(code) : null; + const c = located ? located.context : null; if (c) { if (factory.iteratable()) { return { link: this.path + "/CodeSystem/x-" + factory.id(), - description: csp.display(c) + description: await csp.display(c) } } else { const link = factory.codeLink(c); if (link) { return { link: link, - description: csp.display(c) + description: await csp.display(c) } } } @@ -513,6 +522,35 @@ class Provider { return null; } + /** + * The provider resolveCode() uses for a code system factory in this operation: built + * the first time it is asked for, registered for closing when the operation ends, and + * reused after that. The promise is kept, so calls that overlap share one build. + * + * @param {OperationContext} opContext + * @param {CodeSystemFactoryProvider} factory + * @returns {Promise} + */ + resolveCodeProvider(opContext, factory) { + let built = resolveCodeProviders.get(opContext); + if (!built) { + built = new Map(); + resolveCodeProviders.set(opContext, built); + } + let csp = built.get(factory); + if (!csp) { + csp = (async () => { + const p = await factory.build(opContext, []); + opContext.registerProvider(p); + return p; + })(); + // A failed build is not kept, so the next call tries again. + csp.catch(() => built.delete(factory)); + built.set(factory, csp); + } + return csp; + } + /** * A checker for codes in one code system, so that a page can ask about many codes * without paying to build the code system each time. From 0c6fdc1b99a9dcf3ee07ceab67960afd477ef6ae Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 17:08:47 -0600 Subject: [PATCH 2/3] resolveCodes: look up many codes of one code system together A page that lists the codes of an include asked resolveCode about each one in turn. resolveCodes(opContext, system, version, codes) finds the code system once, gets its provider from the per-operation cache (one build per system and version), and locates the codes eight at a time, returning the results in the order of the codes. resolveCode is now resolveCodes for one code. No CodeSystemProvider in main has a multi-code look-up, so the locates run concurrently with a small limit rather than in one query. A code from a factory gets a link only if the factory is iteratable (US states) or overrides codeLink(); none of the others does, so for RxNorm, LOINC, SNOMED CT and the rest what locate() found was thrown away. Those factories are no longer built or asked. This matters once locate() is awaited: RxNorm looks for a code it does not have in RXNATOMARCHIVE, with no index on RXCUI (36 ms a code), and 4,529 of the 33,341 codes in VSAC's Medication Brand Name are not in the current RxNorm. The results are the same as before. Co-Authored-By: Claude Opus 5.5 --- tests/tx/provider-resolve-code.test.js | 82 +++++++++++++++++ tx/provider.js | 116 ++++++++++++++++++------- 2 files changed, 167 insertions(+), 31 deletions(-) diff --git a/tests/tx/provider-resolve-code.test.js b/tests/tx/provider-resolve-code.test.js index add28527..528af5a3 100644 --- a/tests/tx/provider-resolve-code.test.js +++ b/tests/tx/provider-resolve-code.test.js @@ -11,6 +11,7 @@ const { Provider } = require('../../tx/provider'); const { OperationContext } = require('../../tx/operation-context'); const { Languages } = require('../../library/languages'); +const { CodeSystemFactoryProvider } = require('../../tx/cs/cs-api'); const SYSTEM = 'http://example.org/states'; const CODES = { AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona' }; @@ -130,3 +131,84 @@ describe('resolveCode', () => { expect((await provider.resolveCode(opContext, SYSTEM, null, 'AL')).description).toBe('Alabama'); }); }); + +/** A factory whose locate() waits until released, recording how many run at once. */ +function slowFactory() { + const factory = fakeFactory(); + const build = factory.build; + factory.inFlight = 0; + factory.maxInFlight = 0; + factory.locates = 0; + factory.build = async (...args) => { + const csp = await build(...args); + const locate = csp.locate; + csp.locate = async (code) => { + factory.locates++; + factory.inFlight++; + factory.maxInFlight = Math.max(factory.maxInFlight, factory.inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + factory.inFlight--; + return locate(code); + }; + return csp; + }; + return factory; +} + +describe('resolveCodes', () => { + const many = (n) => Array.from({ length: n }, (_, i) => Object.keys(CODES)[i % 3]); + + test('builds the provider once, and locates the codes several at a time but not all at once', async () => { + const factory = slowFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, many(100)); + expect(factory.builds).toBe(1); + expect(factory.locates).toBe(100); + expect(factory.maxInFlight).toBeGreaterThan(1); + expect(factory.maxInFlight).toBeLessThanOrEqual(8); + // ...and the answers come back in the order of the codes. + expect(results.map((r) => r.description)).toEqual(many(100).map((c) => CODES[c])); + }); + + test('one build per code system across a page, however the codes are asked for', async () => { + const a = slowFactory(); + const b = slowFactory(); + const provider = providerWith({ [SYSTEM + '|a']: a, [SYSTEM + '|b']: b }); + const opContext = newContext(); + await Promise.all([ + provider.resolveCodes(opContext, SYSTEM, 'a', many(30)), + provider.resolveCodes(opContext, SYSTEM, 'b', many(30)), + provider.resolveCodes(opContext, SYSTEM, 'a', many(30)), + provider.resolveCode(opContext, SYSTEM, 'b', 'AL'), + ]); + expect([a.builds, b.builds]).toEqual([1, 1]); + }); + + test('a code it does not have is null, in its place', async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, ['AL', 'XX', 'AZ']); + expect(results.map((r) => r && r.description)).toEqual(['Alabama', null, 'Arizona']); + }); + + test('no codes, no build', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + expect(await provider.resolveCodes(newContext(), SYSTEM, null, [])).toEqual([]); + expect(factory.builds).toBe(0); + }); + + test('a code system that cannot link a code is not built or asked', async () => { + // Like RxNorm or LOINC: not iteratable, and codeLink() is the default, which links nothing. + const factory = Object.assign(Object.create(CodeSystemFactoryProvider.prototype), { + builds: 0, + id: () => 'db', + async build() { factory.builds++; throw new Error('should not be built'); }, + }); + const provider = providerWith({ [SYSTEM]: factory }); + // A CodeSystem resource for the same system still gives its codes a link. + provider.codeSystems.set(SYSTEM, { id: 'states-cs', codeMap: new Map([['AL', { display: 'Alabama' }]]) }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, ['AL', 'AK']); + expect(factory.builds).toBe(0); + expect(results).toEqual([{ link: '/tx/r4/CodeSystem/states-cs#AL', description: 'Alabama' }, null]); + }); +}); diff --git a/tx/provider.js b/tx/provider.js index bdf70879..83c2037f 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -12,6 +12,7 @@ const {PackageValueSetProvider} = require("./vs/vs-package"); const ValueSet = require("./library/valueset"); const {PackageConceptMapProvider} = require("./cm/cm-package"); const { applyIdPrefix, CORE_PREFIX } = require("./library/resource-ids"); +const { CodeSystemFactoryProvider } = require("./cs/cs-api"); /** * The code system providers resolveCode() has built, per operation: opContext -> @@ -22,6 +23,28 @@ const { applyIdPrefix, CORE_PREFIX } = require("./library/resource-ids"); */ const resolveCodeProviders = new WeakMap(); +/** + * How many codes resolveCodes() locates at once. Enough to keep a database-backed code + * system busy without queueing a whole page's worth of queries at the same time. + */ +const RESOLVE_CODES_CONCURRENCY = 8; + +/** + * fn over items, at most `limit` at a time, with the results in the order of the items. + */ +async function mapConcurrently(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker)); + return results; +} + /** * This class holds what information is in context * @@ -462,10 +485,31 @@ class Provider { } async resolveCode(opContext, system, version, code) { + validateParameter(code, "code", String); + return (await this.resolveCodes(opContext, system, version, [code]))[0]; + } + + /** + * resolveCode() for many codes of one code system: where each code lives (a link) and + * its display, or null, in the order of the codes. + * + * The code system is found and its provider built once for all of them, and the codes + * are located RESOLVE_CODES_CONCURRENCY at a time rather than one after another. + * + * @param {OperationContext} opContext + * @param {string} system + * @param {string|null} version + * @param {string[]} codes + * @returns {Promise>} + */ + async resolveCodes(opContext, system, version, codes) { validateParameter(opContext, "opContext", OperationContext); validateParameter(system, "system", String); validateOptionalParameter(version, "version", String); - validateParameter(code, "code", String); + validateArrayParameter(codes, "codes", String); + if (codes.length === 0) { + return []; + } if (system.includes("|")) { const url = system.substring(0, system.indexOf("|")); @@ -485,41 +529,51 @@ class Provider { if (factory == null && vurlMM) { factory = this.codeSystemFactories.get(vurlMM); } - if (factory != null) { - const csp = await this.resolveCodeProvider(opContext, factory); - const located = csp ? await csp.locate(code) : null; - const c = located ? located.context : null; - if (c) { - if (factory.iteratable()) { - return { - link: this.path + "/CodeSystem/x-" + factory.id(), - description: await csp.display(c) - } - } else { - const link = factory.codeLink(c); - if (link) { - return { - link: link, - description: await csp.display(c) - } - } - } - } - } + // A code system from a factory gives a code a link only if the factory is iteratable + // (US states) or has a codeLink() of its own. For the rest (RxNorm, LOINC, SNOMED CT, + // ...) what locate() finds would be thrown away, so they are not built or asked. + // That matters: RxNorm looks for a code it does not have in its archive table, which + // takes 36 ms a code, and 4,529 of Medication Brand Name's codes are not current. + const linksCodes = factory != null + && (factory.iteratable() || factory.codeLink !== CodeSystemFactoryProvider.prototype.codeLink); + const csp = linksCodes ? await this.resolveCodeProvider(opContext, factory) : null; let cs = this.codeSystems.get(vurl); if (cs == null && vurlMM) { cs = this.codeSystems.get(vurlMM); } - if (cs != null) { - const c = cs.codeMap.get(code); - if (c) { - return { - link: this.path + "/CodeSystem/" + cs.id + '#' + code, - description: c.display - }; + + return mapConcurrently(codes, RESOLVE_CODES_CONCURRENCY, async (code) => { + if (csp) { + const located = await csp.locate(code); + const c = located ? located.context : null; + if (c) { + if (factory.iteratable()) { + return { + link: this.path + "/CodeSystem/x-" + factory.id(), + description: await csp.display(c) + } + } else { + const link = factory.codeLink(c); + if (link) { + return { + link: link, + description: await csp.display(c) + } + } + } + } } - } - return null; + if (cs != null) { + const c = cs.codeMap.get(code); + if (c) { + return { + link: this.path + "/CodeSystem/" + cs.id + '#' + code, + description: c.display + }; + } + } + return null; + }); } /** From ffedf98ab89ac3a6ccb045907db06e06bef29346 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 17:08:47 -0600 Subject: [PATCH 3/3] ValueSet page: list the first 500 codes of an include, with a link to show all An include that lists its codes was rendered one row per code, each looked up in the code system: 33,341 rows and look-ups for VSAC's Medication Brand Name. The renderer now lists the first MAX_RENDERED_INCLUDE_CODES (500) codes of an include or exclude, then "... and 32,841 more codes. Show all". "Show all" is the same URL with _showAll=true, which lists every code. The parameter is underscored like the other parameters that control how a result is presented (_format, _summary, _elements); that also keeps it out of the way of $expand parameters, since the page passes its query string to the expansion it shows. A value set in a bundle links to its own page; a page from a POST gives the count without a link. The codes that are listed are looked up with one resolveCodes call per include, when the link resolver has it. Every listed code is still checked for validity, including those not shown. Co-Authored-By: Claude Opus 5.5 --- tests/tx/html-valueset-show-all.test.js | 83 +++++++++++++++ tests/tx/renderer-include-codes.test.js | 134 ++++++++++++++++++++++++ tx/library/renderer.js | 56 +++++++++- tx/tx-html.js | 38 ++++++- 4 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 tests/tx/html-valueset-show-all.test.js create mode 100644 tests/tx/renderer-include-codes.test.js diff --git a/tests/tx/html-valueset-show-all.test.js b/tests/tx/html-valueset-show-all.test.js new file mode 100644 index 00000000..5fddc9da --- /dev/null +++ b/tests/tx/html-valueset-show-all.test.js @@ -0,0 +1,83 @@ +/** + * The HTML page of a value set lists the first 500 codes of each include and links to the + * same page with ?_showAll=true, which lists them all. + */ + +const { TxHtmlRenderer } = require('../../tx/tx-html'); +const { Renderer } = require('../../tx/library/renderer'); + +const big = (n) => ({ + resourceType: 'ValueSet', + id: 'big', + status: 'active', + compose: { + include: [{ + system: 'http://example.org/cs', + concept: Array.from({ length: n }, (_, i) => ({ code: 'c' + i, display: 'Code ' + i })), + }], + }, +}); + +function build() { + const looked = []; + const linkResolver = { + resolveURL: async () => null, + resolveCode: async () => null, + resolveCodes: async (ctx, system, version, codes) => { + looked.push(codes.length); + return codes.map(() => null); + }, + }; + const renderer = new Renderer({ i18n: { formatPhrase: (id) => id, formatPhrasePlural: (id) => id }, langs: null }, linkResolver); + return { looked, html: new TxHtmlRenderer(renderer, null, null, null, '/tx/r4') }; +} + +// The page's own expansion needs a server; without one it reports an error, which is +// not what is being tested. +const get = (url) => { + const query = Object.fromEntries(new URL(url, 'http://localhost').searchParams); + return { method: 'GET', path: '/ValueSet/big', originalUrl: url, query }; +}; + +const rows = (html) => (html.match(/
  • c\d+: /g) || []).length; + +describe('value set page: show all codes', () => { + test('by default, 500 codes and a link to this page with _showAll=true', async () => { + const { looked, html } = build(); + const out = await html.render(big(33341), get('/tx/r4/ValueSet/big?_format=html&lang=en')); + expect(rows(out)).toBe(500); + expect(looked).toEqual([500]); + expect(out).toContain('… and 32,841 more codes. ' + + 'Show all'); + }); + + test('_showAll=true lists every code', async () => { + const { looked, html } = build(); + const out = await html.render(big(33341), get('/tx/r4/ValueSet/big?_format=html&_showAll=true')); + expect(rows(out)).toBe(33341); + expect(looked).toEqual([33341]); + expect(out).not.toContain('more codes'); + }); + + test('in a bundle, the link goes to the value set\'s own page', async () => { + const { html } = build(); + const out = await html.render(big(600), get('/tx/r4/ValueSet?_format=html&_showAll=true'), true); + expect(rows(out)).toBe(500); + expect(out).toContain('Show all'); + }); + + test('a page that came from a POST says how many more, without a link', async () => { + const { html } = build(); + const req = { ...get('/tx/r4/ValueSet/$expand?_format=html'), method: 'POST', path: '/ValueSet/$expand' }; + const out = await html.render(big(600), req); + expect(out).toContain('… and 100 more codes.'); + expect(out).not.toContain('Show all'); + }); + + test('a small value set is listed in full, with no extra line', async () => { + const { html } = build(); + const out = await html.render(big(3), get('/tx/r4/ValueSet/big?_format=html')); + expect(rows(out)).toBe(3); + expect(out).not.toContain('more code'); + }); +}); diff --git a/tests/tx/renderer-include-codes.test.js b/tests/tx/renderer-include-codes.test.js new file mode 100644 index 00000000..eef04f9c --- /dev/null +++ b/tests/tx/renderer-include-codes.test.js @@ -0,0 +1,134 @@ +/** + * A value set that lists its codes is rendered one row per code. VSAC's Medication Brand + * Name lists 33,341 codes in one include, so by default an include shows its first + * MAX_RENDERED_INCLUDE_CODES codes, then a line saying how many more there are, with a + * link to the page that shows them all. + * + * The codes that are shown are looked up for a link and a description in one request to + * the link resolver per include, not one request per code. + */ + +const { Renderer, MAX_RENDERED_INCLUDE_CODES } = require('../../tx/library/renderer'); +const { div } = require('../../library/html'); + +const SYSTEM = 'http://example.org/cs'; + +/** A link resolver that counts its calls, with or without resolveCodes. */ +function setup({ batch = true } = {}) { + const single = []; + const batches = []; + const resolve = (code) => ({ link: 'LINK:' + code, description: 'Described ' + code }); + const linkResolver = { + resolveURL: async () => null, + resolveCode: async (ctx, system, version, code) => { + single.push(code); + return resolve(code); + }, + }; + if (batch) { + linkResolver.resolveCodes = async (ctx, system, version, codes) => { + batches.push({ system, version, codes }); + return codes.map(resolve); + }; + } + const opContext = { i18n: { formatPhrase: (id) => id }, langs: null }; + return { single, batches, renderer: new Renderer(opContext, linkResolver) }; +} + +const include = (n, withDisplay = true) => ({ + system: SYSTEM, + concept: Array.from({ length: n }, (_, i) => (withDisplay ? { code: 'c' + i, display: 'Code ' + i } : { code: 'c' + i })), +}); + +async function render(renderer, inc) { + const x = div(); + await renderer.renderInclude(x, inc); + return x.toString(); +} + +const rows = (html) => (html.match(/
  • /g) || []).length; + +describe('renderInclude: how many codes are listed', () => { + test('the default is 500 codes', () => { + expect(MAX_RENDERED_INCLUDE_CODES).toBe(500); + expect(setup().renderer.includeCodeLimit).toBe(500); + }); + + test('a longer include lists the first 500, then how many more, with a link to them all', async () => { + const { renderer } = setup(); + renderer.showAllCodesHref = '/tx/r4/ValueSet/big?_format=html&_showAll=true'; + const html = await render(renderer, include(33341)); + expect(rows(html)).toBe(501); + expect(html).toContain('c499: Code 499'); + expect(html).not.toContain('c500'); + expect(html).toContain('… and 32,841 more codes. Show all'); + }); + + test('without a link to give, it still says how many more there are', async () => { + const { renderer } = setup(); + const html = await render(renderer, include(501)); + expect(html).toContain('… and 1 more code.'); + expect(html).not.toContain('Show all'); + }); + + test('with no limit, every code is listed and looked up', async () => { + const { batches, renderer } = setup(); + renderer.includeCodeLimit = null; + const html = await render(renderer, include(1200)); + expect(rows(html)).toBe(1200); + expect(html).toContain('c1199: Code 1199'); + expect(html).not.toContain('more code'); + expect(batches[0].codes).toHaveLength(1200); + }); + + test('at 500 codes, nothing is cut', async () => { + const { renderer } = setup(); + const html = await render(renderer, include(500)); + expect(rows(html)).toBe(500); + expect(html).not.toContain('more code'); + }); + + test('every code is still checked, including the ones not listed', async () => { + const { renderer } = setup(); + const inc = include(600); + inc.concept[550].code = ''; + await expect(render(renderer, inc)).rejects.toThrow(); + }); +}); + +describe('renderInclude: look-ups', () => { + test('the listed codes are looked up in one request, and only those', async () => { + const { single, batches, renderer } = setup(); + await render(renderer, { ...include(2000), version: '2' }); + expect(single).toHaveLength(0); + expect(batches).toHaveLength(1); + expect(batches[0].system).toBe(SYSTEM); + expect(batches[0].version).toBe('2'); + expect(batches[0].codes).toEqual(Array.from({ length: 500 }, (_, i) => 'c' + i)); + }); + + test('a resolver without resolveCodes is asked one code at a time', async () => { + const { single, renderer } = setup({ batch: false }); + const html = await render(renderer, include(3)); + expect(single).toEqual(['c0', 'c1', 'c2']); + expect(html).toContain('c0: Code 0'); + }); + + test('a small include renders as it did before', async () => { + const { renderer } = setup(); + renderer.linkResolver.resolveCodes = async (ctx, system, version, codes) => + codes.map((code) => (code === 'c1' ? { link: 'LINK:c1', description: 'Described c1' } : null)); + const html = await render(renderer, { + system: SYSTEM, + concept: [{ code: 'c0', display: 'Code 0' }, { code: 'c1' }, { code: 'c2', display: 'Code 2' }], + }); + // The same as main renders it. + expect(html).toBe('
    \n' + + 'VALUE_SET_THESE_CODES_DEF http://example.org/cs:
      \n' + + '
    • c0: Code 0
    • \n' + + '
    • c1: Described c1
    • \n' + + '
    • c2: Code 2
    • \n' + + '
    \n' + + '
    \n'); + }); +}); diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 075ba373..b22d9af2 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -25,12 +25,22 @@ const VALID_CODESYSTEM_CONTENT = new Set([ ]); const VALID_PUBLICATION_STATUS = new Set(['draft', 'active', 'retired', 'unknown']); +/** + * How many of the codes a ValueSet.compose include (or exclude) lists are rendered by + * default. Past this, the list ends with a line saying how many more there are and, when + * the renderer has been given showAllCodesHref, a link to the page with all of them. + * VSAC's Medication Brand Name lists 33,341 codes in one include. + */ +const MAX_RENDERED_INCLUDE_CODES = 500; + /** * @typedef {Object} TerminologyLinkResolver * @property {function(OperationContext, string, string=): {description: string, link: string}|null} resolveURL * Given a URL and optional version, returns description and link, or null if not found * @property {function(OperationContext, string, string, string=): {display: string, link: string}|null} resolveCode * Given a URL, code, and optional version, returns display and link, or null if not found + * @property {function(OperationContext, string, string, string[]): Array<{description: string, link: string}|null>} [resolveCodes] + * resolveCode for many codes of one code system, in their order; used where present */ class Renderer { @@ -38,6 +48,10 @@ class Renderer { constructor(opContext, linkResolver = null) { this.opContext = opContext; this.linkResolver = linkResolver; + /** How many codes of an include to render; null renders them all. */ + this.includeCodeLimit = MAX_RENDERED_INCLUDE_CODES; + /** Where "Show all" goes when an include is cut short; null for no link. */ + this.showAllCodesHref = null; } displayCoded(...args) { @@ -726,8 +740,14 @@ class Renderer { const ul = li.ul(); for (let c of inc.concept) { this._requireValidCode(c.code, 'ValueSet.compose.include.concept.code'); + } + const limit = this.includeCodeLimit; + const shown = limit != null && inc.concept.length > limit ? inc.concept.slice(0, limit) : inc.concept; + const links = await this.resolveIncludeCodes(inc, shown); + for (let i = 0; i < shown.length; i++) { + const c = shown[i]; + const link = links[i]; const li = ul.li(); - const link = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, c.code) : null; if (link) { li.ah(link.link).tx(c.code); } else { @@ -739,6 +759,15 @@ class Renderer { li.span("opaque: 0.5").tx(": "+link.description); } } + if (shown.length < inc.concept.length) { + const more = inc.concept.length - shown.length; + const li = ul.li(); + li.i().tx(`\u2026 and ${more.toLocaleString('en-US')} more code${more === 1 ? '' : 's'}.`); + if (this.showAllCodesHref) { + li.tx(" "); + li.ah(this.showAllCodesHref).tx("Show all"); + } + } } else { li.tx(this.translate('VALUE_SET_CODES_FROM')+" "); await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : "")); @@ -777,6 +806,29 @@ class Renderer { } } + /** + * The link and description for each of the codes of an include that are shown, in one + * request to the link resolver for the include's code system where it can take one. + * + * @param {Object} inc the include + * @param {Object[]} concepts the include's concepts being rendered + * @returns {Promise>} + */ + async resolveIncludeCodes(inc, concepts) { + if (!this.linkResolver || concepts.length === 0) { + return concepts.map(() => null); + } + const codes = concepts.map((c) => c.code); + if (this.linkResolver.resolveCodes) { + return await this.linkResolver.resolveCodes(this.opContext, inc.system, inc.version, codes); + } + const links = []; + for (const code of codes) { + links.push(await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, code)); + } + return links; + } + async generateProperties(x, cs) { if (!cs.property || cs.property.length === 0) { return false; @@ -2769,4 +2821,4 @@ class Renderer { } -module.exports = { Renderer, VALID_FILTER_OPS }; +module.exports = { Renderer, VALID_FILTER_OPS, MAX_RENDERED_INCLUDE_CODES }; diff --git a/tx/tx-html.js b/tx/tx-html.js index 1c614fc1..91681423 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -21,7 +21,7 @@ const {ParametersXML} = require("./xml/parameters-xml"); const {OperationOutcomeXML} = require("./xml/operationoutcome-xml"); const {debugLog} = require("./operation-context"); const {InvalidError} = require("./library/errors"); -const {VALID_FILTER_OPS} = require("./library/renderer"); +const {VALID_FILTER_OPS, MAX_RENDERED_INCLUDE_CODES} = require("./library/renderer"); const txOpenApi = require("./openapi"); const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' }); @@ -61,6 +61,12 @@ const ELEMENT_OPTIONS = ['id', 'url', 'version', 'name', 'title', 'status', 'dat // something, not about terminology, so it does not belong in the FHIR tools namespace. const SNOMED_URI = 'http://snomed.info/sct'; +// A value set page lists the first MAX_RENDERED_INCLUDE_CODES codes of each include; +// ?_showAll=true lists them all. Underscored, as the other parameters that control how a +// result is presented are (_format, _summary, _elements), which also keeps it apart from +// the $expand parameters: the page's query string is passed to the expansion it shows. +const SHOW_ALL_PARAM = '_showAll'; + const TRANSIENT_VALUESET = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient'; const SEVERITY_ALERT_CLASS = { @@ -592,6 +598,7 @@ class TxHtmlRenderer { case 'CodeSystem': return await this.renderCodeSystem(json, inBundle, _fmt, op, req.sourcePackage); case 'ValueSet': { + this.limitIncludeCodes(json, req, inBundle); let exp = undefined; if (!inBundle && !op && (!_fmt || _fmt == 'html')) { try { @@ -626,6 +633,35 @@ class TxHtmlRenderer { } } + /** + * How many codes of each include the value set's page lists, and where its "Show all" + * link goes: this page with ?_showAll=true, or, for a value set shown in a bundle, the + * value set's own page. A page that came from a POST has no URL to ask again with, so + * it says how many codes there are but has no link. + * + * @param {Object} json the value set + * @param {express.Request|null} req + * @param {boolean} inBundle + */ + limitIncludeCodes(json, req, inBundle) { + if (!inBundle && req?.query?.[SHOW_ALL_PARAM] === 'true') { + this.renderer.includeCodeLimit = null; + this.renderer.showAllCodesHref = null; + return; + } + this.renderer.includeCodeLimit = MAX_RENDERED_INCLUDE_CODES; + if (inBundle) { + this.renderer.showAllCodesHref = json.id + ? `${this.path}/ValueSet/${encodeURIComponent(json.id)}?_format=html&${SHOW_ALL_PARAM}=true` : null; + } else if (req?.method === 'GET' && req.originalUrl) { + const url = new URL(req.originalUrl, 'http://localhost'); + url.searchParams.set(SHOW_ALL_PARAM, 'true'); + this.renderer.showAllCodesHref = url.pathname + url.search; + } else { + this.renderer.showAllCodesHref = null; + } + } + /** * Render Parameters resource */