diff --git a/server.js b/server.js
index c38ef244..52626e38 100644
--- a/server.js
+++ b/server.js
@@ -430,6 +430,11 @@ async function buildRootPageContent() {
content += `
Process Memory: ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%) | `;
content += `System Memory: ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%) | `;
content += '';
+ content += '';
+ content += `| Expansion Cache: ${stats.expansionItems()} items | `;
+ content += `Client Cache: ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts | `;
+ content += `Max Client Cache: ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts | `;
+ content += '
';
content += getLogStats();
content += '';
diff --git a/stats.js b/stats.js
index fdeedeb7..f450b295 100644
--- a/stats.js
+++ b/stats.js
@@ -36,24 +36,26 @@ class ServerStats {
: (now - this.startTime) / 60000;
const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0;
- const currentCpu = this.readSystemCpu();
- const idleDelta = currentCpu.idle - this.lastUsage.idle;
- const totalDelta = currentCpu.total - this.lastUsage.total;
- const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0;
-
const loopDelay = this.eventLoopMonitor.mean / 1e6;
- let cacheCount = 0;
+ // Two distinct caches: the expansion cache (count entries) and the client
+ // (resource) cache (count concepts held - a sense of how much it's carrying).
+ let expansionItems = 0;
+ let clientConcepts = 0;
for (let m of this.cachingModules) {
- cacheCount = cacheCount + m.cacheCount();
+ if (typeof m.expansionItemCount === 'function') {
+ expansionItems = expansionItems + m.expansionItemCount();
+ }
+ if (typeof m.clientConceptCount === 'function') {
+ clientConcepts = clientConcepts + m.clientConceptCount();
+ }
}
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount});
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts});
this.eventLoopMonitor.reset();
this.requestCountSnapshot = combinedCount;
this.requestTime = 0;
this.lastTime = now;
- this.lastUsage = currentCpu;
// Prune old data (keep 24 hours)
const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
@@ -65,7 +67,6 @@ class ServerStats {
this.started = true;
this.startMem = process.memoryUsage().heapUsed;
this.startTime = Date.now();
- this.lastUsage = this.readSystemCpu();
this.lastTime = this.startTime;
this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
this.eventLoopMonitor.enable();
@@ -141,17 +142,24 @@ class ServerStats {
clearInterval(this.timer);
}
- readSystemCpu() {
- const os = require('os');
- const cpus = os.cpus();
- let idle = 0, total = 0;
- for (const cpu of cpus) {
- idle += cpu.times.idle;
- total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
+ // Live cache aggregates across all registered caching modules (defensive: a
+ // module that doesn't expose a given metric contributes 0).
+ _sumCachingModules(method) {
+ let total = 0;
+ for (let m of this.cachingModules) {
+ if (typeof m[method] === 'function') {
+ total = total + m[method]();
+ }
}
- return { idle, total };
+ return total;
}
+ expansionItems() { return this._sumCachingModules('expansionItemCount'); }
+ clientCaches() { return this._sumCachingModules('clientCacheCount'); }
+ clientConcepts() { return this._sumCachingModules('clientConceptCount'); }
+ maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
+ maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
+
getTaskColor(status) {
switch (status) {
case "started": return "LightGrey";
diff --git a/tests/tx/cache-control.test.js b/tests/tx/cache-control.test.js
new file mode 100644
index 00000000..ab82324d
--- /dev/null
+++ b/tests/tx/cache-control.test.js
@@ -0,0 +1,207 @@
+/**
+ * $cache-control routing smoke tests
+ *
+ * At this stage the operation is scaffolding only: start/end parse the request but
+ * do nothing yet. These tests just confirm the route is wired up and dispatches on
+ * the `mode` query parameter. Behavioural tests come with the implementation.
+ */
+
+const request = require('supertest');
+const { getTestApp, shutdownTestApp } = require('./setup');
+
+const BASE = '/tx/r5/$cache-control';
+
+describe('$cache-control routing (scaffolding)', () => {
+ let app;
+
+ beforeAll(async () => {
+ app = await getTestApp();
+ }, 60000);
+
+ afterAll(async () => {
+ await shutdownTestApp();
+ });
+
+ // An empty Parameters body stands in for "no front-loaded resources". A real
+ // client always sets a Content-Type on a POST; the endpoint's media-type gate
+ // rejects a truly bodyless POST with 415 (see note - bodyless POST is a separate
+ // decision).
+ const emptyBody = { resourceType: 'Parameters', parameter: [] };
+
+ test('POST mode=start resolves and returns a Parameters', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'start' })
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send(emptyBody);
+ expect(res.status).toBe(200);
+ expect(res.body.resourceType).toBe('Parameters');
+ });
+
+ test('GET mode=start resolves (browsable, like the other operations)', async () => {
+ const res = await request(app)
+ .get(BASE)
+ .query({ mode: 'start' })
+ .set('Accept', 'application/json');
+ expect(res.status).toBe(200);
+ expect(res.body.resourceType).toBe('Parameters');
+ });
+
+ test('POST mode=end resolves and returns a Parameters', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'end' })
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', 'some-cache-id')
+ .send(emptyBody);
+ expect(res.status).toBe(200);
+ expect(res.body.resourceType).toBe('Parameters');
+ });
+
+ test('missing mode is a 400 OperationOutcome', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send(emptyBody);
+ expect(res.status).toBe(400);
+ expect(res.body.resourceType).toBe('OperationOutcome');
+ });
+
+ test('unknown mode is a 400 OperationOutcome', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'bogus' })
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send(emptyBody);
+ expect(res.status).toBe(400);
+ expect(res.body.resourceType).toBe('OperationOutcome');
+ });
+
+ test('mode supplied as a Parameters body parameter is accepted as a fallback', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send({ resourceType: 'Parameters', parameter: [{ name: 'mode', valueCode: 'start' }] });
+ expect(res.status).toBe(200);
+ expect(res.body.resourceType).toBe('Parameters');
+ });
+
+ // ---- behaviour ----
+
+ function cacheIdFrom(body) {
+ const p = (body.parameter || []).find(x => x.name === 'cache-id');
+ return p ? p.valueId : undefined;
+ }
+
+ test('start returns a server-issued cache-id', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'start' })
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send(emptyBody);
+ expect(res.status).toBe(200);
+ const id = cacheIdFrom(res.body);
+ expect(typeof id).toBe('string');
+ expect(id).toMatch(/^[0-9a-f-]{36}$/i); // UUID shape
+ });
+
+ test('each start mints a distinct cache-id', async () => {
+ const a = await request(app).post(BASE).query({ mode: 'start' })
+ .set('Content-Type', 'application/json').send(emptyBody);
+ const b = await request(app).post(BASE).query({ mode: 'start' })
+ .set('Content-Type', 'application/json').send(emptyBody);
+ expect(cacheIdFrom(a.body)).not.toBe(cacheIdFrom(b.body));
+ });
+
+ test('start front-loads supplied resources into the cache, retrievable by url', async () => {
+ const colorsCS = {
+ resourceType: 'CodeSystem',
+ url: 'http://example.org/cc-test/colors',
+ version: '1.0.0',
+ status: 'active',
+ content: 'complete',
+ concept: [{ code: 'red', display: 'Red' }]
+ };
+ const colorsVS = {
+ resourceType: 'ValueSet',
+ url: 'http://example.org/cc-test/colors-vs',
+ version: '1.0.0',
+ status: 'active',
+ compose: { include: [{ system: colorsCS.url }] }
+ };
+
+ // Front-load CS + VS via start.
+ const started = await request(app)
+ .post(BASE)
+ .query({ mode: 'start' })
+ .set('Content-Type', 'application/json')
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS }
+ ]
+ });
+ const cacheId = cacheIdFrom(started.body);
+ expect(cacheId).toBeDefined();
+
+ // The front-loaded ValueSet should now resolve by url under that cache-id.
+ // (validate still reads cache-id from the parameter in this step; the header
+ // read path is a later change.)
+ const validated = await request(app)
+ .post('/tx/r5/ValueSet/$validate-code')
+ .set('Content-Type', 'application/json')
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'cache-id', valueId: cacheId },
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]
+ });
+ expect(validated.status).toBe(200);
+ const result = (validated.body.parameter || []).find(x => x.name === 'result');
+ expect(result && result.valueBoolean).toBe(true);
+ });
+
+ test('end with the cache-id header releases the cache', async () => {
+ const started = await request(app).post(BASE).query({ mode: 'start' })
+ .set('Content-Type', 'application/json').send(emptyBody);
+ const cacheId = cacheIdFrom(started.body);
+
+ const ended = await request(app)
+ .post(BASE)
+ .query({ mode: 'end' })
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', cacheId)
+ .send(emptyBody);
+ expect(ended.status).toBe(200);
+ expect(ended.body.resourceType).toBe('Parameters');
+ });
+
+ test('end without the cache-id header is a 400', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'end' })
+ .set('Content-Type', 'application/json')
+ .send(emptyBody);
+ expect(res.status).toBe(400);
+ expect(res.body.resourceType).toBe('OperationOutcome');
+ });
+
+ test('end of an unknown cache-id is a tolerant no-op (200)', async () => {
+ const res = await request(app)
+ .post(BASE)
+ .query({ mode: 'end' })
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', 'never-issued-this-id')
+ .send(emptyBody);
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/tests/tx/cache-id-params.test.js b/tests/tx/cache-id-params.test.js
new file mode 100644
index 00000000..77d53acb
--- /dev/null
+++ b/tests/tx/cache-id-params.test.js
@@ -0,0 +1,103 @@
+/**
+ * Cache-id parameter extraction tests
+ *
+ * Regression guard for the bug where the cache-id protocol was silently disabled
+ * because getParameterValue() did not understand the `valueId` element. fhir-core
+ * sends cache-id as an IdType, i.e. {"name":"cache-id","valueId":"..."}, so if
+ * getParameterValue() omits valueId it returns null, cacheId becomes null, and the
+ * server never caches or resolves anything by reference -- every by-reference
+ * validation then fails with "value set ... could not be found".
+ *
+ * These are pure unit tests against the parameter helpers on TerminologyWorker;
+ * they do not need a running server.
+ */
+
+const { TerminologyWorker } = require('../../tx/workers/worker');
+const { OperationContext } = require('../../tx/operation-context');
+const { TestUtilities } = require('../test-utilities');
+
+class MockProvider {
+ loadSupplements() { return []; }
+}
+
+class TestWorker extends TerminologyWorker {
+ opName() { return 'test'; }
+ vsHandle() { return null; }
+}
+
+describe('cache-id parameter extraction', () => {
+ let worker;
+
+ beforeEach(async () => {
+ const opContext = new OperationContext('en-US', await TestUtilities.loadTranslations(), 'test-123');
+ worker = new TestWorker(opContext, { info: () => {} }, new MockProvider(), {}, { });
+ });
+
+ describe('getParameterValue - primitive value[x] types', () => {
+ // The one that actually broke production: cache-id arrives as valueId.
+ test('reads valueId (the real cache-id wire format from fhir-core)', () => {
+ const param = { name: 'cache-id', valueId: '25180033-f4f5-41e8-8880-6f5252da0dd2' };
+ expect(worker.getParameterValue(param)).toBe('25180033-f4f5-41e8-8880-6f5252da0dd2');
+ });
+
+ test.each([
+ ['valueString', 'hello'],
+ ['valueCode', 'active'],
+ ['valueId', 'abc-123'],
+ ['valueUri', 'http://example.org/x'],
+ ['valueCanonical', 'http://example.org/x|1.0.0'],
+ ['valueUrl', 'http://example.org/x'],
+ ['valueBoolean', true],
+ ['valueInteger', 42],
+ ['valueDecimal', 3.14],
+ ['valueDateTime', '2026-01-01T00:00:00Z'],
+ ])('reads %s', (type, value) => {
+ expect(worker.getParameterValue({ name: 'p', [type]: value })).toBe(value);
+ });
+
+ test('reads valueBoolean=false (must not be treated as missing)', () => {
+ expect(worker.getParameterValue({ name: 'p', valueBoolean: false })).toBe(false);
+ });
+
+ test('reads resource over value[x] when both present', () => {
+ const res = { resourceType: 'ValueSet', url: 'http://x' };
+ expect(worker.getParameterValue({ name: 'p', resource: res })).toBe(res);
+ });
+
+ test('returns null for a parameter with no value', () => {
+ expect(worker.getParameterValue({ name: 'p' })).toBeNull();
+ });
+
+ test('returns null for null/undefined param', () => {
+ expect(worker.getParameterValue(null)).toBeNull();
+ expect(worker.getParameterValue(undefined)).toBeNull();
+ });
+ });
+
+ describe('findParameter + getParameterValue together (the cache-id read path)', () => {
+ function params(...parameter) {
+ return { resourceType: 'Parameters', parameter };
+ }
+
+ test('cache-id as valueId is found and read (the regression)', () => {
+ const p = params(
+ { name: 'code', valueCode: 'application/pdf' },
+ { name: 'cache-id', valueId: 'cid-1' }
+ );
+ const found = worker.findParameter(p, 'cache-id');
+ expect(found).not.toBeNull();
+ expect(worker.getParameterValue(found)).toBe('cid-1');
+ });
+
+ test('cache-id as valueString is also found and read', () => {
+ const p = params({ name: 'cache-id', valueString: 'cid-2' });
+ expect(worker.getParameterValue(worker.findParameter(p, 'cache-id'))).toBe('cid-2');
+ });
+
+ test('missing cache-id yields null (no false cache-id)', () => {
+ const p = params({ name: 'code', valueCode: 'x' });
+ const found = worker.findParameter(p, 'cache-id');
+ expect(found).toBeNull();
+ });
+ });
+});
diff --git a/tests/tx/cache-id.test.js b/tests/tx/cache-id.test.js
new file mode 100644
index 00000000..a7ca4f0b
--- /dev/null
+++ b/tests/tx/cache-id.test.js
@@ -0,0 +1,301 @@
+/**
+ * cache-id protocol integration tests (HTTP, end-to-end)
+ *
+ * The cache-id protocol lets a client send a CodeSystem/ValueSet once under a
+ * `cache-id`, then refer to it by url on later requests with the same cache-id.
+ * This is in routine use by every fhir-core consumer, so it must be exercised hard.
+ *
+ * Background on the two bugs these tests guard:
+ * 1. fhir-core sends cache-id as an IdType -> {"name":"cache-id","valueId":"..."}.
+ * If the server can't read valueId, cacheId is null and the whole protocol
+ * silently no-ops, so every by-reference call fails "could not be found".
+ * 2. The server must cache the primary `valueSet` parameter (not only
+ * `tx-resource`), or the by-reference follow-up can't resolve the main VS.
+ *
+ * These run over real HTTP against the test app (R5 core auto-loaded).
+ */
+
+const request = require('supertest');
+const { getTestApp, shutdownTestApp } = require('./setup');
+
+const VALIDATE = '/tx/r5/ValueSet/$validate-code';
+const EXPAND = '/tx/r5/ValueSet/$expand';
+
+// Self-contained fixtures so we don't depend on what's in the core package.
+const colorsCS = {
+ resourceType: 'CodeSystem',
+ url: 'http://example.org/cache-id-test/colors',
+ version: '1.0.0',
+ status: 'active',
+ content: 'complete',
+ concept: [
+ { code: 'red', display: 'Red' },
+ { code: 'green', display: 'Green' },
+ { code: 'blue', display: 'Blue' }
+ ]
+};
+
+const colorsVS = {
+ resourceType: 'ValueSet',
+ url: 'http://example.org/cache-id-test/colors-vs',
+ version: '1.0.0',
+ status: 'active',
+ compose: { include: [{ system: colorsCS.url }] }
+};
+
+// Send cache-id either as valueId (fhir-core's real format) or valueString.
+function cacheIdParam(value, as) {
+ return as === 'valueString'
+ ? { name: 'cache-id', valueString: value }
+ : { name: 'cache-id', valueId: value };
+}
+
+function post(app, url, parameter) {
+ return request(app)
+ .post(url)
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send({ resourceType: 'Parameters', parameter });
+}
+
+// Create a real cache via $cache-control and return its server-issued id. Caches
+// must now exist before they can be referenced; this is how a client gets one.
+async function startCache(app, parameter = []) {
+ const res = await request(app)
+ .post('/tx/r5/$cache-control')
+ .query({ mode: 'start' })
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send({ resourceType: 'Parameters', parameter });
+ const p = (res.body.parameter || []).find(x => x.name === 'cache-id');
+ return p ? p.valueId : undefined;
+}
+
+// Pull the boolean `result` out of a $validate-code Parameters response.
+function resultOf(body) {
+ if (!body || body.resourceType !== 'Parameters') return undefined;
+ const p = (body.parameter || []).find(x => x.name === 'result');
+ return p ? p.valueBoolean : undefined;
+}
+
+// True if the response is the coded "unknown cache" error.
+function isUnknownCacheError(res) {
+ if (res.body.resourceType !== 'OperationOutcome') return false;
+ return (res.body.issue || []).some(i =>
+ (i.details && (i.details.coding || []).some(c => c.code === 'cache-id-unknown')));
+}
+
+describe('cache-id protocol (integration)', () => {
+ let app;
+
+ beforeAll(async () => {
+ app = await getTestApp();
+ }, 60000);
+
+ afterAll(async () => {
+ await shutdownTestApp();
+ });
+
+ // The headline regression: cache-id as valueId, register inline then validate by url.
+ describe.each([
+ ['valueId (fhir-core wire format)', 'valueId'],
+ ['valueString', 'valueString'],
+ ])('$validate-code round trip with cache-id as %s', (_label, as) => {
+ test('register CS+VS under cache-id, then validate a valid code by url', async () => {
+ const cid = await startCache(app);
+
+ // 1st call: send CS (tx-resource) and the primary VS (inline) under cache-id.
+ const r1 = await post(app, VALIDATE, [
+ cacheIdParam(cid, as),
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]);
+ expect(r1.status).toBe(200);
+ expect(resultOf(r1.body)).toBe(true);
+
+ // 2nd call: refer to the VS by url only, same cache-id, nothing re-sent.
+ const r2 = await post(app, VALIDATE, [
+ cacheIdParam(cid, as),
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]);
+ expect(r2.status).toBe(200);
+ expect(resultOf(r2.body)).toBe(true);
+ });
+
+ test('by-reference validation of an invalid code returns result=false (really validating)', async () => {
+ const cid = await startCache(app);
+ await post(app, VALIDATE, [
+ cacheIdParam(cid, as),
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]);
+
+ const r2 = await post(app, VALIDATE, [
+ cacheIdParam(cid, as),
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'magenta' } }
+ ]);
+ expect(r2.status).toBe(200);
+ expect(resultOf(r2.body)).toBe(false);
+ });
+ });
+
+ describe('unknown cache-id is a specific, coded, server-authoritative error', () => {
+ test('a started cache resolves by url; a never-issued cache-id is the coded unknown-cache error', async () => {
+ const cid = await startCache(app);
+ // Register under the started cache.
+ const reg = await post(app, VALIDATE, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } }
+ ]);
+ expect(resultOf(reg.body)).toBe(true);
+
+ // Same cache-id, by url -> resolves from cache.
+ const same = await post(app, VALIDATE, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } }
+ ]);
+ expect(resultOf(same.body)).toBe(true);
+
+ // A cache-id the server never issued -> coded unknown-cache error, NOT a
+ // silent success and NOT an obscure "value set not found".
+ const other = await post(app, VALIDATE, [
+ { name: 'cache-id', valueId: 'never-issued-this-cache' },
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } }
+ ]);
+ expect(other.status).toBe(404);
+ expect(isUnknownCacheError(other)).toBe(true);
+ });
+
+ test('unknown cache-id supplied via the x-cache-id header is also the coded error', async () => {
+ const res = await request(app)
+ .post(VALIDATE)
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', 'never-issued-this-cache')
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } }
+ ]
+ });
+ expect(res.status).toBe(404);
+ expect(isUnknownCacheError(res)).toBe(true);
+ });
+
+ test('$expand with an unknown cache-id is the coded error too', async () => {
+ const res = await post(app, EXPAND, [
+ { name: 'cache-id', valueId: 'never-issued-this-cache' },
+ { name: 'url', valueString: colorsVS.url }
+ ]);
+ expect(res.status).toBe(404);
+ expect(isUnknownCacheError(res)).toBe(true);
+ });
+
+ test('by url with no cache-id and a VS not in the library fails (not a silent pass)', async () => {
+ const r = await post(app, VALIDATE, [
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]);
+ expect(resultOf(r.body)).not.toBe(true);
+ });
+ });
+
+ describe('cache-id carried as the x-cache-id header (going-forward transport)', () => {
+ test('full round trip with the cache-id only ever sent as a header', async () => {
+ const cid = await startCache(app);
+
+ const reg = await request(app)
+ .post(VALIDATE)
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', cid)
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]
+ });
+ expect(reg.status).toBe(200);
+ expect(resultOf(reg.body)).toBe(true);
+
+ const byUrl = await request(app)
+ .post(VALIDATE)
+ .set('Content-Type', 'application/json')
+ .set('x-cache-id', cid)
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'url', valueString: colorsVS.url },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
+ ]
+ });
+ expect(byUrl.status).toBe(200);
+ expect(resultOf(byUrl.body)).toBe(true);
+ });
+ });
+
+ describe('cached tx-resource CodeSystem is reused by a later inline ValueSet', () => {
+ test('CS sent once under cache-id; a second VS using it validates without re-sending the CS', async () => {
+ const cid = await startCache(app);
+
+ // 1st call registers the CS (and a first VS) under the cache-id.
+ const r1 = await post(app, VALIDATE, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'blue' } }
+ ]);
+ expect(resultOf(r1.body)).toBe(true);
+
+ // 2nd call: a DIFFERENT inline VS over the same CS, CS not re-sent.
+ const secondVS = {
+ resourceType: 'ValueSet',
+ url: 'http://example.org/cache-id-test/colors-vs-2',
+ version: '1.0.0',
+ status: 'active',
+ compose: { include: [{ system: colorsCS.url, concept: [{ code: 'blue' }] }] }
+ };
+ const r2 = await post(app, VALIDATE, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'valueSet', resource: secondVS },
+ { name: 'coding', valueCoding: { system: colorsCS.url, code: 'blue' } }
+ ]);
+ expect(r2.status).toBe(200);
+ expect(resultOf(r2.body)).toBe(true);
+ });
+ });
+
+ describe('$expand cache-id round trip', () => {
+ test('register VS+CS via cache-id (valueId), then expand by url', async () => {
+ const cid = await startCache(app);
+
+ const r1 = await post(app, EXPAND, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'tx-resource', resource: colorsCS },
+ { name: 'valueSet', resource: colorsVS }
+ ]);
+ expect(r1.status).toBe(200);
+ expect(r1.body.resourceType).toBe('ValueSet');
+
+ const r2 = await post(app, EXPAND, [
+ cacheIdParam(cid, 'valueId'),
+ { name: 'url', valueString: colorsVS.url }
+ ]);
+ expect(r2.status).toBe(200);
+ expect(r2.body.resourceType).toBe('ValueSet');
+ expect(r2.body.expansion).toBeDefined();
+ const codes = (r2.body.expansion.contains || []).map(c => c.code).sort();
+ expect(codes).toEqual(['blue', 'green', 'red']);
+ });
+ });
+});
diff --git a/tests/tx/concept-count.test.js b/tests/tx/concept-count.test.js
new file mode 100644
index 00000000..45f20cd8
--- /dev/null
+++ b/tests/tx/concept-count.test.js
@@ -0,0 +1,109 @@
+/**
+ * conceptCount() tests for CodeSystem, ValueSet, ConceptMap.
+ *
+ * conceptCount() is precalculated at construction and gives a cheap O(1) sense of
+ * how large a cached resource is (used by cache sizing/limits). It counts the
+ * concepts *present in the resource*, not the size of a value set's full expansion.
+ */
+
+const { CodeSystem } = require('../../tx/library/codesystem');
+const ValueSet = require('../../tx/library/valueset');
+const { ConceptMap } = require('../../tx/library/conceptmap');
+
+describe('CodeSystem.conceptCount()', () => {
+ test('counts a flat concept list', () => {
+ const cs = new CodeSystem({
+ resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'complete',
+ concept: [{ code: 'a' }, { code: 'b' }, { code: 'c' }]
+ });
+ expect(cs.conceptCount()).toBe(3);
+ });
+
+ test('counts nested concepts recursively', () => {
+ const cs = new CodeSystem({
+ resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'complete',
+ concept: [
+ { code: 'a', concept: [{ code: 'a1' }, { code: 'a2', concept: [{ code: 'a2i' }] }] },
+ { code: 'b' }
+ ]
+ });
+ // a, a1, a2, a2i, b = 5
+ expect(cs.conceptCount()).toBe(5);
+ });
+
+ test('is 0 when there are no concepts (e.g. not-present / grammar systems)', () => {
+ const cs = new CodeSystem({
+ resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'not-present'
+ });
+ expect(cs.conceptCount()).toBe(0);
+ });
+});
+
+describe('ValueSet.conceptCount()', () => {
+ test('counts enumerated concepts in compose include and exclude', () => {
+ const vs = new ValueSet({
+ resourceType: 'ValueSet', url: 'http://x/vs', status: 'active',
+ compose: {
+ include: [{ system: 'http://x/cs', concept: [{ code: 'a' }, { code: 'b' }, { code: 'c' }] }],
+ exclude: [{ system: 'http://x/cs', concept: [{ code: 'c' }] }]
+ }
+ });
+ expect(vs.conceptCount()).toBe(4); // 3 include + 1 exclude
+ });
+
+ test('counts an inline expansion (contains) recursively', () => {
+ const vs = new ValueSet({
+ resourceType: 'ValueSet', url: 'http://x/vs', status: 'active',
+ expansion: {
+ contains: [
+ { system: 'http://x/cs', code: 'a' },
+ { system: 'http://x/cs', code: 'b', contains: [{ system: 'http://x/cs', code: 'b1' }] }
+ ]
+ }
+ });
+ expect(vs.conceptCount()).toBe(3); // a, b, b1
+ });
+
+ test('counts compose + inline expansion together', () => {
+ const vs = new ValueSet({
+ resourceType: 'ValueSet', url: 'http://x/vs', status: 'active',
+ compose: { include: [{ system: 'http://x/cs', concept: [{ code: 'a' }] }] },
+ expansion: { contains: [{ system: 'http://x/cs', code: 'a' }, { system: 'http://x/cs', code: 'b' }] }
+ });
+ expect(vs.conceptCount()).toBe(3); // 1 compose + 2 expansion
+ });
+
+ test('is 0 for an include-by-system with no enumerated concepts', () => {
+ const vs = new ValueSet({
+ resourceType: 'ValueSet', url: 'http://x/vs', status: 'active',
+ compose: { include: [{ system: 'urn:ietf:bcp:13' }] }
+ });
+ expect(vs.conceptCount()).toBe(0);
+ });
+});
+
+describe('ConceptMap.conceptCount()', () => {
+ test('counts elements plus targets across groups', () => {
+ const cm = new ConceptMap({
+ resourceType: 'ConceptMap', url: 'http://x/cm', status: 'active',
+ group: [
+ {
+ source: 'http://x/cs1', target: 'http://x/cs2',
+ element: [
+ { code: 'a', target: [{ code: 'A', relationship: 'equivalent' }] },
+ { code: 'b', target: [{ code: 'B1', relationship: 'equivalent' }, { code: 'B2', relationship: 'equivalent' }] }
+ ]
+ }
+ ]
+ });
+ // elements: a, b (2) + targets: A, B1, B2 (3) = 5
+ expect(cm.conceptCount()).toBe(5);
+ });
+
+ test('is 0 with no groups', () => {
+ const cm = new ConceptMap({
+ resourceType: 'ConceptMap', url: 'http://x/cm', status: 'active'
+ });
+ expect(cm.conceptCount()).toBe(0);
+ });
+});
diff --git a/tests/tx/expand.test.js b/tests/tx/expand.test.js
index ea2ae56f..82ab9fb4 100644
--- a/tests/tx/expand.test.js
+++ b/tests/tx/expand.test.js
@@ -7,6 +7,18 @@
const request = require('supertest');
const { getTestApp, shutdownTestApp } = require('./setup');
+// Caches must be created explicitly via $cache-control before a cache-id can be
+// referenced. Returns the server-issued id.
+async function startCache(app) {
+ const res = await request(app)
+ .post('/tx/r5/$cache-control')
+ .query({ mode: 'start' })
+ .set('Content-Type', 'application/json')
+ .send({ resourceType: 'Parameters', parameter: [] });
+ const p = (res.body.parameter || []).find(x => x.name === 'cache-id');
+ return p ? p.valueId : undefined;
+}
+
describe('Expand Worker', () => {
let app;
@@ -288,7 +300,7 @@ describe('Expand Worker', () => {
});
test('should cache tx-resource with cache-id and reuse in subsequent request', async () => {
- const cacheId = 'test-cache-colors-' + Date.now();
+ const cacheId = await startCache(app);
// First request: submit CodeSystem with cache-id
const response1 = await request(app)
@@ -345,6 +357,55 @@ describe('Expand Worker', () => {
// expect(response2.body.expansion.contains.map(c => c.code)).toEqual(['red', 'blue']);
});
+ test('should cache the primary valueSet under cache-id and resolve it by url', async () => {
+ // Regression for the cache-id protocol gap: fhir-core registers the *primary*
+ // ValueSet by sending it inline as the `valueSet` parameter on the first call,
+ // then on later calls refers to it by url alone with the same cache-id. The
+ // server must retain that primary ValueSet, otherwise the by-url follow-up
+ // fails with "value set could not be found".
+ const cacheId = await startCache(app);
+
+ // First request: primary ValueSet sent inline as `valueSet`, plus the
+ // CodeSystem it needs as tx-resource, both under the cache-id.
+ // NB: cache-id is sent as `valueId` here, exactly as fhir-core sends it
+ // (IdType). getParameterValue must understand valueId or the cache-id is
+ // read as null and the whole protocol silently no-ops.
+ const response1 = await request(app)
+ .post('/tx/r5/ValueSet/$expand')
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'cache-id', valueId: cacheId },
+ { name: 'tx-resource', resource: testCodeSystem },
+ { name: 'valueSet', resource: testValueSet }
+ ]
+ });
+
+ expect(response1.status).toBe(200);
+ expect(response1.body.resourceType).toBe('ValueSet');
+
+ // Second request: refer to the primary ValueSet by url only, same cache-id,
+ // with neither the valueSet nor the CodeSystem re-sent. Both must come from
+ // the cache. Before the fix this returned 422 "could not be found".
+ const response2 = await request(app)
+ .post('/tx/r5/ValueSet/$expand')
+ .set('Accept', 'application/json')
+ .set('Content-Type', 'application/json')
+ .send({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'cache-id', valueId: cacheId },
+ { name: 'url', valueString: testValueSet.url }
+ ]
+ });
+
+ expect(response2.status).toBe(200);
+ expect(response2.body.resourceType).toBe('ValueSet');
+ expect(response2.body.expansion).toBeDefined();
+ });
+
test('should fail without cache-id when CodeSystem not provided', async () => {
// This request provides a ValueSet that references a CodeSystem
// that isn't in the provider and isn't provided via tx-resource
diff --git a/tests/tx/renderer-resources.test.js b/tests/tx/renderer-resources.test.js
index c549d00a..8809ef5e 100644
--- a/tests/tx/renderer-resources.test.js
+++ b/tests/tx/renderer-resources.test.js
@@ -236,3 +236,50 @@ describe('displayDate remains lenient (separate from resource validation)', () =
expect(renderer.displayDate('2024-13-45')).toBe('2024-13-45');
});
});
+
+// ─── Parameters primitive value rendering (valueId etc.) ─────────────────────
+
+describe('Parameters value rendering covers id-family primitives', () => {
+ test('valueId is rendered, not shown as (empty) - the cache-id case', async () => {
+ const html = await txHtml.renderParameters({
+ resourceType: 'Parameters',
+ parameter: [{ name: 'cache-id', valueId: '70995493-fd31-477e-b570-e0a2b3275bb5' }]
+ });
+ expect(html).toContain('70995493-fd31-477e-b570-e0a2b3275bb5');
+ expect(html).not.toContain('(empty)');
+ });
+
+ test('renders the other id-family / numeric primitives', async () => {
+ const cases = [
+ { name: 'oid', valueOid: 'urn:oid:1.2.3' , expect: 'urn:oid:1.2.3' },
+ { name: 'uuid', valueUuid: 'urn:uuid:abc', expect: 'urn:uuid:abc' },
+ { name: 'i64', valueInteger64: '9007199254740993', expect: '9007199254740993' },
+ { name: 'pos', valuePositiveInt: 5, expect: '5' },
+ { name: 'uns', valueUnsignedInt: 0, expect: '0' }
+ ];
+ for (const c of cases) {
+ const html = await txHtml.renderParameters({ resourceType: 'Parameters', parameter: [c] });
+ expect(html).toContain(c.expect);
+ expect(html).not.toContain('(empty)');
+ }
+ });
+
+ test('valueMarkdown is rendered as HTML (consistent with the rest of the server)', async () => {
+ const html = await txHtml.renderParameters({
+ resourceType: 'Parameters',
+ parameter: [{ name: 'doc', valueMarkdown: 'see **the docs** for details' }]
+ });
+ // The rendered table cell contains real HTML (the JSON-source block below the
+ // table still echoes the raw markdown, so we only assert the rendered form).
+ expect(html).toContain('the docs');
+ expect(html).not.toContain('(empty)');
+ });
+
+ test('valueMarkdown rendering is XSS-safe (raw HTML escaped)', async () => {
+ const html = await txHtml.renderParameters({
+ resourceType: 'Parameters',
+ parameter: [{ name: 'doc', valueMarkdown: 'hi ' }]
+ });
+ expect(html).not.toContain('');
+ });
+});
diff --git a/tests/tx/resource-cache.test.js b/tests/tx/resource-cache.test.js
new file mode 100644
index 00000000..e44cb076
--- /dev/null
+++ b/tests/tx/resource-cache.test.js
@@ -0,0 +1,242 @@
+/**
+ * ResourceCache unit tests
+ *
+ * ResourceCache backs the cache-id protocol: resources sent under a cache-id on one
+ * request must be retrievable on later requests with the same cache-id, and kept
+ * isolated between different cache-ids.
+ */
+
+const { ResourceCache } = require('../../tx/operation-context');
+
+const CS = (url, version) => ({ resourceType: 'CodeSystem', url, version });
+const VS = (url, version) => ({ resourceType: 'ValueSet', url, version });
+
+// A resource that reports a concept count, like the real wrapper classes do.
+const sized = (resource, n) => ({ ...resource, conceptCount: () => n });
+
+describe('ResourceCache', () => {
+ let cache;
+
+ beforeEach(() => {
+ cache = new ResourceCache(null);
+ });
+
+ describe('get / has on empty cache', () => {
+ test('get returns empty array for unknown cache-id', () => {
+ expect(cache.get('nope')).toEqual([]);
+ });
+ test('has is false for unknown cache-id', () => {
+ expect(cache.has('nope')).toBe(false);
+ });
+ });
+
+ describe('add', () => {
+ test('stores resources under a cache-id and reports has()', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ expect(cache.has('c1')).toBe(true);
+ expect(cache.get('c1')).toHaveLength(1);
+ });
+
+ test('merges across multiple add() calls under the same cache-id', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ cache.add('c1', [VS('http://vs', '1.0.0')]);
+ const got = cache.get('c1');
+ expect(got).toHaveLength(2);
+ expect(got.map(r => r.resourceType).sort()).toEqual(['CodeSystem', 'ValueSet']);
+ });
+
+ test('replaces an existing resource with the same url+version+type', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ const updated = { ...CS('http://cs', '1.0.0'), title: 'updated' };
+ cache.add('c1', [updated]);
+ const got = cache.get('c1');
+ expect(got).toHaveLength(1);
+ expect(got[0].title).toBe('updated');
+ });
+
+ test('keeps resources with the same url but different versions as distinct', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ cache.add('c1', [CS('http://cs', '2.0.0')]);
+ expect(cache.get('c1')).toHaveLength(2);
+ });
+
+ test('treats a CodeSystem and ValueSet at the same url as distinct', () => {
+ cache.add('c1', [CS('http://same', '1.0.0')]);
+ cache.add('c1', [VS('http://same', '1.0.0')]);
+ expect(cache.get('c1')).toHaveLength(2);
+ });
+
+ test('ignores empty / null resource lists', () => {
+ cache.add('c1', []);
+ cache.add('c1', null);
+ expect(cache.has('c1')).toBe(false);
+ });
+ });
+
+ describe('isolation between cache-ids', () => {
+ test('resources under one cache-id are not visible under another', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ expect(cache.get('c2')).toEqual([]);
+ expect(cache.has('c2')).toBe(false);
+ });
+
+ test('two cache-ids hold independent contents', () => {
+ cache.add('c1', [CS('http://a', '1')]);
+ cache.add('c2', [CS('http://b', '1')]);
+ expect(cache.get('c1').map(r => r.url)).toEqual(['http://a']);
+ expect(cache.get('c2').map(r => r.url)).toEqual(['http://b']);
+ });
+ });
+
+ describe('get returns a copy', () => {
+ test('mutating the returned array does not affect the cache', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0')]);
+ const got = cache.get('c1');
+ got.push(VS('http://injected', '1'));
+ expect(cache.get('c1')).toHaveLength(1);
+ });
+ });
+
+ describe('set (replace all)', () => {
+ test('replaces the entire contents for a cache-id', () => {
+ cache.add('c1', [CS('http://cs', '1.0.0'), VS('http://vs', '1.0.0')]);
+ cache.set('c1', [CS('http://only', '1.0.0')]);
+ const got = cache.get('c1');
+ expect(got).toHaveLength(1);
+ expect(got[0].url).toBe('http://only');
+ });
+ });
+
+ describe('clear / clearAll / size', () => {
+ test('clear removes a single cache-id', () => {
+ cache.add('c1', [CS('http://a', '1')]);
+ cache.add('c2', [CS('http://b', '1')]);
+ cache.clear('c1');
+ expect(cache.has('c1')).toBe(false);
+ expect(cache.has('c2')).toBe(true);
+ expect(cache.size()).toBe(1);
+ });
+
+ test('clearAll empties the cache', () => {
+ cache.add('c1', [CS('http://a', '1')]);
+ cache.add('c2', [CS('http://b', '1')]);
+ cache.clearAll();
+ expect(cache.size()).toBe(0);
+ });
+ });
+
+ describe('prune', () => {
+ test('removes entries older than maxAge but keeps fresh ones', () => {
+ cache.add('old', [CS('http://old', '1')]);
+ // Force the entry to look stale.
+ cache.cache.get('old').lastUsed = Date.now() - 10000;
+ cache.add('fresh', [CS('http://fresh', '1')]);
+
+ cache.prune(5000); // maxAge 5s
+
+ expect(cache.has('old')).toBe(false);
+ expect(cache.has('fresh')).toBe(true);
+ });
+
+ test('get() refreshes lastUsed so an active entry is not pruned', () => {
+ cache.add('c1', [CS('http://a', '1')]);
+ cache.cache.get('c1').lastUsed = Date.now() - 10000;
+ cache.get('c1'); // touch -> refreshes lastUsed
+ cache.prune(5000);
+ expect(cache.has('c1')).toBe(true);
+ });
+ });
+
+ describe('running concept count', () => {
+ test('starts at zero', () => {
+ expect(cache.conceptCount()).toBe(0);
+ });
+
+ test('add accumulates the total and per-cache subtotal', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10), sized(VS('http://b', '1'), 3)]);
+ expect(cache.conceptCount()).toBe(13);
+ expect(cache.conceptCountFor('c1')).toBe(13);
+ });
+
+ test('resources with no conceptCount() count as zero', () => {
+ cache.add('c1', [CS('http://a', '1')]); // plain object, no conceptCount
+ expect(cache.conceptCount()).toBe(0);
+ });
+
+ test('replacing a resource adjusts by the difference', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ expect(cache.conceptCount()).toBe(10);
+ cache.add('c1', [sized(CS('http://a', '1'), 4)]); // same key, fewer concepts
+ expect(cache.conceptCount()).toBe(4);
+ expect(cache.conceptCountFor('c1')).toBe(4);
+ });
+
+ test('totals are kept separate per cache-id', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ cache.add('c2', [sized(CS('http://b', '1'), 5)]);
+ expect(cache.conceptCount()).toBe(15);
+ expect(cache.conceptCountFor('c1')).toBe(10);
+ expect(cache.conceptCountFor('c2')).toBe(5);
+ });
+
+ test('set replaces the entry contribution', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10), sized(VS('http://b', '1'), 5)]);
+ expect(cache.conceptCount()).toBe(15);
+ cache.set('c1', [sized(CS('http://c', '1'), 2)]);
+ expect(cache.conceptCount()).toBe(2);
+ expect(cache.conceptCountFor('c1')).toBe(2);
+ });
+
+ test('clear subtracts the cleared entry', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ cache.add('c2', [sized(CS('http://b', '1'), 5)]);
+ cache.clear('c1');
+ expect(cache.conceptCount()).toBe(5);
+ expect(cache.conceptCountFor('c1')).toBe(0);
+ });
+
+ test('clearAll resets the total to zero', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ cache.add('c2', [sized(CS('http://b', '1'), 5)]);
+ cache.clearAll();
+ expect(cache.conceptCount()).toBe(0);
+ });
+
+ test('prune subtracts evicted entries', () => {
+ cache.add('old', [sized(CS('http://old', '1'), 7)]);
+ cache.cache.get('old').lastUsed = Date.now() - 10000;
+ cache.add('fresh', [sized(CS('http://fresh', '1'), 4)]);
+ cache.prune(5000);
+ expect(cache.conceptCount()).toBe(4);
+ });
+ });
+
+ describe('high-water marks (max)', () => {
+ test('track the most caches and concepts ever held', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ cache.add('c2', [sized(CS('http://b', '1'), 5)]);
+ expect(cache.maxSize()).toBe(2);
+ expect(cache.maxConceptCount()).toBe(15);
+ });
+
+ test('max is not reduced when entries are cleared', () => {
+ cache.add('c1', [sized(CS('http://a', '1'), 10)]);
+ cache.add('c2', [sized(CS('http://b', '1'), 5)]);
+ cache.clear('c1');
+ cache.clear('c2');
+ expect(cache.size()).toBe(0);
+ expect(cache.conceptCount()).toBe(0);
+ // high-water marks persist
+ expect(cache.maxSize()).toBe(2);
+ expect(cache.maxConceptCount()).toBe(15);
+ });
+
+ test('max grows but never shrinks across set/prune', () => {
+ cache.set('c1', [sized(CS('http://a', '1'), 20)]);
+ expect(cache.maxConceptCount()).toBe(20);
+ cache.set('c1', [sized(CS('http://a', '1'), 3)]); // shrink live count
+ expect(cache.conceptCount()).toBe(3);
+ expect(cache.maxConceptCount()).toBe(20);
+ });
+ });
+});
diff --git a/tests/tx/tx-module.test.js b/tests/tx/tx-module.test.js
index 27aa1b83..4c6deb06 100644
--- a/tests/tx/tx-module.test.js
+++ b/tests/tx/tx-module.test.js
@@ -83,6 +83,33 @@ describe('TX Module', () => {
expect(opNames).toContain('validate-code');
expect(opNames).toContain('subsumes');
});
+
+ test('advertises the $cache-control operation at the system level', async () => {
+ const response = await request(app)
+ .get('/tx/r5/metadata')
+ .set('Accept', 'application/json');
+ expect(response.status).toBe(200);
+
+ const rest = response.body.rest[0];
+ const systemOps = (rest.operation || []).map(o => o.name);
+ expect(systemOps).toContain('cache-control');
+ const cc = rest.operation.find(o => o.name === 'cache-control');
+ expect(cc.definition).toBe('http://hl7.org/fhir/tools/OperationDefinition/cache-control');
+ });
+
+ test('does NOT advertise cache-id as an expansion parameter (old implicit protocol retired)', async () => {
+ const response = await request(app)
+ .get('/tx/r5/metadata')
+ .query({ mode: 'terminology' })
+ .set('Accept', 'application/json');
+ expect(response.status).toBe(200);
+ expect(response.body.resourceType).toBe('TerminologyCapabilities');
+
+ const expansionParams = ((response.body.expansion || {}).parameter || []).map(p => p.name);
+ expect(expansionParams).not.toContain('cache-id');
+ // tx-resource is still supported and still advertised
+ expect(expansionParams).toContain('tx-resource');
+ });
});
describe('GET /tx/r5/', () => {
diff --git a/translations/Messages.properties b/translations/Messages.properties
index 31376529..b5ad9a61 100644
--- a/translations/Messages.properties
+++ b/translations/Messages.properties
@@ -1100,6 +1100,7 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion
UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it
Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
+CACHE_ID_UNKNOWN = The cache ''{0}'' is not known to this server. Caches are created with $cache-control?mode=start; this one was never created, or has expired or been released
Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
Undefined_element_ = Undefined element ''{0}'' at {1}
diff --git a/tx/html/home-metrics.liquid b/tx/html/home-metrics.liquid
index 57001b05..8816b4d9 100644
--- a/tx/html/home-metrics.liquid
+++ b/tx/html/home-metrics.liquid
@@ -4,7 +4,7 @@
-
Cache
+ Expansion Cache
@@ -18,8 +18,8 @@
-
CPU Usage (%)
-
+ Client Cache
+
Node Blocking
@@ -99,13 +99,13 @@
}
});
- // Cache chart
+ // Expansion cache chart
new Chart(document.getElementById('cacheChart'), {
type: 'line',
data: {
labels: historyData.map(d => formatTime(d.time)),
datasets: [{
- data: historyData.map(d => d.cache),
+ data: historyData.map(d => d.expansion),
borderColor: '#7b7b7b',
backgroundColor: 'rgba(123, 123, 123, 0.1)',
fill: true,
@@ -121,7 +121,7 @@
...chartOptions.scales.y,
title: {
display: true,
- text: '# cached items'
+ text: 'Expansion cache # items'
}
}
}
@@ -158,13 +158,13 @@
}
});
- // CPU chart
- new Chart(document.getElementById('cpuChart'), {
+ // Client cache concepts chart
+ new Chart(document.getElementById('clientCacheChart'), {
type: 'line',
data: {
labels: historyData.map(d => formatTime(d.time)),
datasets: [{
- data: historyData.map(d => parseFloat(formatMB(d.cpu))),
+ data: historyData.map(d => d.clientConcepts),
borderColor: '#ff7b00',
backgroundColor: 'rgba(255, 123, 0, 0.1)',
fill: true,
@@ -180,7 +180,7 @@
...chartOptions.scales.y,
title: {
display: true,
- text: '%'
+ text: 'Client Cache # concepts'
}
}
}
diff --git a/tx/library/codesystem.js b/tx/library/codesystem.js
index 29354e85..a6240f54 100644
--- a/tx/library/codesystem.js
+++ b/tx/library/codesystem.js
@@ -42,6 +42,37 @@ class CodeSystem extends CanonicalResource {
}
this.buildMaps();
}
+ // Precalculated at construction so callers (e.g. the resource cache) have a
+ // cheap O(1) sense of how large this resource is.
+ this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept);
+ }
+
+ /**
+ * Number of concepts defined in this CodeSystem, counted recursively (nested
+ * concepts included). Precalculated at construction time.
+ * @returns {number}
+ */
+ conceptCount() {
+ return this._conceptCount;
+ }
+
+ /**
+ * Recursively count concepts (including nested `concept` children).
+ * @param {Array} concepts
+ * @returns {number}
+ */
+ static _countConcepts(concepts) {
+ if (!Array.isArray(concepts)) {
+ return 0;
+ }
+ let n = 0;
+ for (const c of concepts) {
+ n++;
+ if (c && c.concept) {
+ n += CodeSystem._countConcepts(c.concept);
+ }
+ }
+ return n;
}
/**
diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js
index ae420235..a6343b34 100644
--- a/tx/library/conceptmap.js
+++ b/tx/library/conceptmap.js
@@ -20,6 +20,30 @@ class ConceptMap extends CanonicalResource {
this.jsonObj = conceptMapToR5(jsonObj, fhirVersion);
this.validate();
this.id = this.jsonObj.id;
+ // Precalculated at construction so callers (e.g. the resource cache) have a
+ // cheap O(1) sense of how large this resource is.
+ this._conceptCount = ConceptMap._countConcepts(this.jsonObj);
+ }
+
+ /**
+ * Number of concept nodes this ConceptMap carries: every source `element` plus
+ * every `target` across all groups (each is a coded node, so this approximates
+ * the resource's size). Precalculated at construction time.
+ * @returns {number}
+ */
+ conceptCount() {
+ return this._conceptCount;
+ }
+
+ static _countConcepts(jsonObj) {
+ let n = 0;
+ for (const g of ((jsonObj && jsonObj.group) || [])) {
+ for (const e of (g.element || [])) {
+ n++;
+ n += Array.isArray(e.target) ? e.target.length : 0;
+ }
+ }
+ return n;
}
/**
diff --git a/tx/library/valueset.js b/tx/library/valueset.js
index 3b632ce9..3addac55 100644
--- a/tx/library/valueset.js
+++ b/tx/library/valueset.js
@@ -25,6 +25,52 @@ class ValueSet extends CanonicalResource {
this.jsonObj = valueSetToR5(jsonObj, fhirVersion);
this.validate();
this.buildMaps();
+ // Precalculated at construction so callers (e.g. the resource cache) have a
+ // cheap O(1) sense of how large this resource is.
+ this._conceptCount = ValueSet._countConcepts(this.jsonObj);
+ }
+
+ /**
+ * Number of concepts this ValueSet carries: the enumerated concepts in its
+ * compose (include + exclude `concept` lists) plus any concepts in an inline
+ * expansion (`expansion.contains`, counted recursively). Note this counts the
+ * concepts *present in the resource*, not the (possibly unbounded) size of the
+ * value set's full expansion. Precalculated at construction time.
+ * @returns {number}
+ */
+ conceptCount() {
+ return this._conceptCount;
+ }
+
+ static _countConcepts(jsonObj) {
+ let n = 0;
+ const compose = jsonObj && jsonObj.compose;
+ if (compose) {
+ for (const inc of (compose.include || [])) {
+ n += Array.isArray(inc.concept) ? inc.concept.length : 0;
+ }
+ for (const exc of (compose.exclude || [])) {
+ n += Array.isArray(exc.concept) ? exc.concept.length : 0;
+ }
+ }
+ if (jsonObj && jsonObj.expansion) {
+ n += ValueSet._countContains(jsonObj.expansion.contains);
+ }
+ return n;
+ }
+
+ static _countContains(items) {
+ if (!Array.isArray(items)) {
+ return 0;
+ }
+ let n = 0;
+ for (const it of items) {
+ n++;
+ if (it && it.contains) {
+ n += ValueSet._countContains(it.contains);
+ }
+ }
+ return n;
}
/**
diff --git a/tx/operation-context.js b/tx/operation-context.js
index cccce84d..f42ec8b5 100644
--- a/tx/operation-context.js
+++ b/tx/operation-context.js
@@ -3,6 +3,7 @@ const inspector = require("inspector");
const crypto = require("crypto");
const {Languages} = require("../library/languages");
const {Issue} = require("./library/operation-outcome");
+const Logger = require("../library/logger");
/**
* Check if running under a debugger
@@ -59,6 +60,37 @@ class ResourceCache {
this.stats = stats;
this.cache = new Map();
this.locks = new Map(); // For thread-safety with async operations
+ this.log = Logger.getInstance().child({module: 'tx-cache'});
+ // Running total of concepts across every resource in every entry, maintained
+ // incrementally on each mutation so cache sizing/limits are O(1) to consult.
+ // Each entry also carries its own `concepts` subtotal so removal/replacement
+ // can adjust the total without rescanning.
+ this.totalConcepts = 0;
+ // High-water marks (never decremented) - the most caches and the most concepts
+ // this cache has held at once, for capacity reporting.
+ this.maxConcepts = 0;
+ this.maxSizeValue = 0;
+ }
+
+ /**
+ * Update the high-water marks after a mutation that may have grown the cache.
+ */
+ _trackMax() {
+ if (this.totalConcepts > this.maxConcepts) {
+ this.maxConcepts = this.totalConcepts;
+ }
+ if (this.cache.size > this.maxSizeValue) {
+ this.maxSizeValue = this.cache.size;
+ }
+ }
+
+ /**
+ * Concept count of a single cached resource, or 0 if it doesn't expose one.
+ * @param {Object} resource
+ * @returns {number}
+ */
+ _conceptsOf(resource) {
+ return resource && typeof resource.conceptCount === 'function' ? resource.conceptCount() : 0;
}
/**
@@ -70,8 +102,10 @@ class ResourceCache {
const entry = this.cache.get(cacheId);
if (entry) {
entry.lastUsed = Date.now();
+ this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`);
return [...entry.resources]; // Return a copy
}
+ this.log.info(`cache-id '${cacheId}': miss (no entry)`);
return [];
}
@@ -92,22 +126,32 @@ class ResourceCache {
add(cacheId, resources) {
if (!resources || resources.length === 0) return;
- const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now() };
+ const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 };
- // Merge resources, avoiding duplicates by url+version
+ // Merge resources, avoiding duplicates by url+version. Keep the entry's concept
+ // subtotal and the cache-wide total in step with each insertion/replacement.
for (const resource of resources) {
const key = this._resourceKey(resource);
+ const newConcepts = this._conceptsOf(resource);
const existingIndex = entry.resources.findIndex(r => this._resourceKey(r) === key);
if (existingIndex >= 0) {
- // Replace existing
+ // Replace existing: adjust by the difference
+ const delta = newConcepts - this._conceptsOf(entry.resources[existingIndex]);
entry.resources[existingIndex] = resource;
+ entry.concepts += delta;
+ this.totalConcepts += delta;
+ this.log.info(`cache-id '${cacheId}': replaced ${key}`);
} else {
entry.resources.push(resource);
+ entry.concepts += newConcepts;
+ this.totalConcepts += newConcepts;
+ this.log.info(`cache-id '${cacheId}': added ${key}`);
}
}
entry.lastUsed = Date.now();
this.cache.set(cacheId, entry);
+ this._trackMax();
}
/**
@@ -116,10 +160,23 @@ class ResourceCache {
* @param {Array} resources - Resources to set
*/
set(cacheId, resources) {
+ this.log.info(`cache-id '${cacheId}': set (replace all) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
+ // Drop the old entry's contribution, then count the replacement.
+ const existing = this.cache.get(cacheId);
+ if (existing) {
+ this.totalConcepts -= existing.concepts || 0;
+ }
+ let concepts = 0;
+ for (const resource of resources) {
+ concepts += this._conceptsOf(resource);
+ }
+ this.totalConcepts += concepts;
this.cache.set(cacheId, {
resources: [...resources],
- lastUsed: Date.now()
+ lastUsed: Date.now(),
+ concepts
});
+ this._trackMax();
}
/**
@@ -127,6 +184,10 @@ class ResourceCache {
* @param {string} cacheId - The cache identifier
*/
clear(cacheId) {
+ const entry = this.cache.get(cacheId);
+ if (entry) {
+ this.totalConcepts -= entry.concepts || 0;
+ }
this.cache.delete(cacheId);
}
@@ -135,6 +196,7 @@ class ResourceCache {
*/
clearAll() {
this.cache.clear();
+ this.totalConcepts = 0;
}
/**
@@ -150,6 +212,7 @@ class ResourceCache {
for (const [cacheId, entry] of this.cache.entries()) {
if (now - entry.lastUsed > maxAge) {
i++;
+ this.totalConcepts -= entry.concepts || 0;
this.cache.delete(cacheId);
}
}
@@ -166,6 +229,41 @@ class ResourceCache {
return this.cache.size;
}
+ /**
+ * Total number of concepts held across every entry in the cache. Maintained
+ * incrementally, so this is O(1).
+ * @returns {number}
+ */
+ conceptCount() {
+ return this.totalConcepts;
+ }
+
+ /**
+ * Highest number of concepts this cache has held at once (high-water mark).
+ * @returns {number}
+ */
+ maxConceptCount() {
+ return this.maxConcepts;
+ }
+
+ /**
+ * Highest number of caches (cache-ids) this cache has held at once.
+ * @returns {number}
+ */
+ maxSize() {
+ return this.maxSizeValue;
+ }
+
+ /**
+ * Number of concepts held under a single cache-id (0 if unknown).
+ * @param {string} cacheId
+ * @returns {number}
+ */
+ conceptCountFor(cacheId) {
+ const entry = this.cache.get(cacheId);
+ return entry ? (entry.concepts || 0) : 0;
+ }
+
/**
* Generate a key for a resource based on url and version
* @param {Object} resource - The resource
diff --git a/tx/tx-html.js b/tx/tx-html.js
index f477d19c..8fb5a41d 100644
--- a/tx/tx-html.js
+++ b/tx/tx-html.js
@@ -471,6 +471,33 @@ class TxHtmlRenderer {
if (param.valueCode !== undefined) {
return `${escape(param.valueCode)}`;
}
+ if (param.valueId !== undefined) {
+ return escape(String(param.valueId));
+ }
+ if (param.valueOid !== undefined) {
+ return escape(String(param.valueOid));
+ }
+ if (param.valueUuid !== undefined) {
+ return escape(String(param.valueUuid));
+ }
+ if (param.valueMarkdown !== undefined) {
+ // Render markdown to HTML the same way the rest of the server does, using
+ // commonmark in safe mode (raw HTML in the markdown is escaped, so this is
+ // XSS-safe).
+ const commonmark = require('commonmark');
+ const reader = new commonmark.Parser();
+ const writer = new commonmark.HtmlRenderer({ safe: true });
+ return writer.render(reader.parse(String(param.valueMarkdown)));
+ }
+ if (param.valueInteger64 !== undefined) {
+ return escape(String(param.valueInteger64));
+ }
+ if (param.valuePositiveInt !== undefined) {
+ return escape(String(param.valuePositiveInt));
+ }
+ if (param.valueUnsignedInt !== undefined) {
+ return escape(String(param.valueUnsignedInt));
+ }
if (param.valueDate !== undefined) {
return escape(param.valueDate);
}
diff --git a/tx/tx.js b/tx/tx.js
index b46ac2c7..a883861c 100644
--- a/tx/tx.js
+++ b/tx/tx.js
@@ -27,6 +27,7 @@ const LookupWorker = require('./workers/lookup');
const SubsumesWorker = require('./workers/subsumes');
const { MetadataHandler } = require('./workers/metadata');
const { BatchValidateWorker } = require('./workers/batch-validate');
+const { CacheControlWorker } = require('./workers/cache-control');
const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml");
const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml");
const {ParametersXML} = require("./xml/parameters-xml");
@@ -655,6 +656,26 @@ class TXModule {
}
});
+ // $cache-control (GET and POST) - base-level operation: mode=start|end
+ router.get('/\\$cache-control', async (req, res) => {
+ const start = Date.now();
+ try {
+ let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
+ await worker.handle(req, res, this.log);
+ } finally {
+ this.countRequest('$cache-control', Date.now() - start);
+ }
+ });
+ router.post('/\\$cache-control', async (req, res) => {
+ const start = Date.now();
+ try {
+ let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
+ await worker.handle(req, res, this.log);
+ } finally {
+ this.countRequest('$cache-control', Date.now() - start);
+ }
+ });
+
// ConceptMap/$translate (GET and POST)
router.get('/ConceptMap/\\$translate', async (req, res) => {
const start = Date.now();
@@ -1177,10 +1198,46 @@ class TXModule {
}
}
- cacheCount() {
+ // Number of entries in the expansion cache, summed across endpoints.
+ expansionItemCount() {
+ let count = 0;
+ for (let ep of this.endpoints) {
+ count = count + ep.expansionCache.size();
+ }
+ return count;
+ }
+
+ // Number of concepts held in the client (resource) cache, summed across endpoints.
+ clientConceptCount() {
+ let count = 0;
+ for (let ep of this.endpoints) {
+ count = count + ep.resourceCache.conceptCount();
+ }
+ return count;
+ }
+
+ // Number of caches (cache-ids) currently held in the client cache, across endpoints.
+ clientCacheCount() {
+ let count = 0;
+ for (let ep of this.endpoints) {
+ count = count + ep.resourceCache.size();
+ }
+ return count;
+ }
+
+ // High-water marks for the client cache, summed across endpoints.
+ maxClientCacheCount() {
+ let count = 0;
+ for (let ep of this.endpoints) {
+ count = count + ep.resourceCache.maxSize();
+ }
+ return count;
+ }
+
+ maxClientConceptCount() {
let count = 0;
for (let ep of this.endpoints) {
- count = count + ep.resourceCache.size() + ep.expansionCache.size();
+ count = count + ep.resourceCache.maxConceptCount();
}
return count;
}
diff --git a/tx/workers/cache-control.js b/tx/workers/cache-control.js
new file mode 100644
index 00000000..2171fe3f
--- /dev/null
+++ b/tx/workers/cache-control.js
@@ -0,0 +1,186 @@
+//
+// Cache Control Worker - Handles the $cache-control operation
+//
+// GET /$cache-control?mode=start - create a cache, return its (server-issued) id
+// POST /$cache-control?mode=start - as above, optionally front-loading resources
+// GET /$cache-control?mode=end - tell the server it can release the cache now
+// POST /$cache-control?mode=end
+// (mode=check is reserved for later - report whether a cache is still valid + stats)
+//
+// This is the explicit replacement for the implicit `cache-id` parameter protocol:
+// the server owns the cache-id namespace, so it can authoritatively reject an
+// unknown/expired cache later instead of failing obscurely deep inside a validation.
+//
+// NOTE: this is scaffolding. start()/end() currently parse the request into a
+// Parameters resource (the same way validate.js does) but do not yet create or
+// release anything. The behaviour is filled in by later steps.
+//
+
+const crypto = require('crypto');
+const { TerminologyWorker, CACHE_ID_HEADER } = require('./worker');
+const { Parameters } = require('../library/parameters');
+const { debugLog } = require('../operation-context');
+
+class CacheControlWorker extends TerminologyWorker {
+ /**
+ * @param {OperationContext} opContext - Operation context
+ * @param {Logger} log - Logger instance
+ * @param {Provider} provider - Provider for code systems and resources
+ * @param {LanguageDefinitions} languages - Language definitions
+ * @param {I18nSupport} i18n - Internationalization support
+ */
+ constructor(opContext, log, provider, languages, i18n) {
+ super(opContext, log, provider, languages, i18n);
+ }
+
+ opName() {
+ return 'cache-control';
+ }
+
+ // Not a value-set operation; the base class requires this to be implemented.
+ vsHandle() {
+ return null;
+ }
+
+ /**
+ * Express entry point for /$cache-control (GET and POST).
+ * Dispatches on the `mode` query parameter.
+ * @param {express.Request} req - Express request
+ * @param {express.Response} res - Express response
+ */
+ async handle(req, res) {
+ try {
+ const mode = this.getMode(req);
+ // GET is accepted as well as POST, for consistency with the server's other
+ // operations (which are all browsable) and the convenience of poking
+ // $cache-control from a browser. The operation is declared affectsState=true
+ // in its OperationDefinition, so conformant clients POST; an accidental
+ // GET-created cache is an empty entry that self-expires.
+ switch (mode) {
+ case 'start':
+ return await this.start(req, res);
+ case 'end':
+ return await this.end(req, res);
+ default:
+ return res.status(400).json(this.operationOutcome('error', 'invalid',
+ `$cache-control requires a 'mode' of 'start' or 'end'` +
+ (mode ? ` (got '${mode}')` : ` (none supplied)`)));
+ }
+ } catch (error) {
+ this.log.error(error);
+ debugLog(error);
+ req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : '');
+ const statusCode = error.statusCode || 500;
+ const issueCode = error.issueCode || 'exception';
+ return res.status(statusCode).json(this.operationOutcome('error', issueCode, error.message));
+ }
+ }
+
+ /**
+ * Determine the requested mode. The cache-control mode travels in the query
+ * string (?mode=start) so it survives even when a Parameters resource is POSTed
+ * as the body; a `mode` parameter in the body is accepted as a fallback.
+ * @param {express.Request} req
+ * @returns {string|null}
+ */
+ getMode(req) {
+ if (req.query && req.query.mode) {
+ return String(req.query.mode);
+ }
+ if (req.body && req.body.resourceType === 'Parameters') {
+ const p = this.findParameter(req.body, 'mode');
+ if (p) {
+ return this.getParameterValue(p);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * mode=start: mint a server-issued cache-id, create the (per-endpoint) cache,
+ * front-load any supplied resources into it, and return the id in the body.
+ *
+ * The id is returned in the body rather than a header to keep it readable by
+ * browser clients without CORS expose-header configuration; subsequent calls
+ * carry it back as the `${CACHE_ID_HEADER}` request header.
+ *
+ * The cache is created even when no resources are front-loaded: an explicitly
+ * empty cache must still *exist* so the server can later tell "cache I issued,
+ * currently empty" from "cache-id I never issued". That's why this uses
+ * ResourceCache.set (which always creates the entry) rather than add (which
+ * ignores empty resource lists).
+ *
+ * @param {express.Request} req
+ * @param {express.Response} res
+ */
+ async start(req, res) {
+ const cache = this.opContext.resourceCache;
+ if (!cache) {
+ return res.status(500).json(this.operationOutcome('error', 'exception',
+ 'No resource cache is available on this endpoint'));
+ }
+
+ const params = new Parameters(this.buildParameters(req));
+ const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj);
+ const resources = txResources.concat(primaryResources);
+
+ const cacheId = crypto.randomUUID();
+ cache.set(cacheId, resources);
+
+ return res.status(200).json({
+ resourceType: 'Parameters',
+ parameter: [
+ { name: 'cache-id', valueId: cacheId }
+ ]
+ });
+ }
+
+ /**
+ * mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the
+ * server can reclaim it now rather than waiting for the idle timeout.
+ *
+ * Releasing is idempotent: ending an id the server doesn't have is not an error
+ * here (the authoritative "unknown cache" signal belongs on the validation path,
+ * a later step). A missing header is a client error, though.
+ *
+ * @param {express.Request} req
+ * @param {express.Response} res
+ */
+ async end(req, res) {
+ const cache = this.opContext.resourceCache;
+ if (!cache) {
+ return res.status(500).json(this.operationOutcome('error', 'exception',
+ 'No resource cache is available on this endpoint'));
+ }
+
+ const cacheId = req.headers[CACHE_ID_HEADER];
+ if (!cacheId) {
+ return res.status(400).json(this.operationOutcome('error', 'invalid',
+ `$cache-control mode=end requires the cache-id in the '${CACHE_ID_HEADER}' header`));
+ }
+
+ cache.clear(cacheId);
+
+ return res.status(200).json({ resourceType: 'Parameters', parameter: [] });
+ }
+
+ /**
+ * Build an OperationOutcome.
+ * @param {string} severity - error, warning, information
+ * @param {string} code - Issue code
+ * @param {string} message - Diagnostic message
+ * @returns {Object} OperationOutcome resource
+ */
+ operationOutcome(severity, code, message) {
+ return {
+ resourceType: 'OperationOutcome',
+ issue: [{
+ severity,
+ code,
+ details: { text: message }
+ }]
+ };
+ }
+}
+
+module.exports = { CacheControlWorker, CACHE_ID_HEADER };
diff --git a/tx/workers/metadata.js b/tx/workers/metadata.js
index 34f94138..7e788f0c 100644
--- a/tx/workers/metadata.js
+++ b/tx/workers/metadata.js
@@ -267,6 +267,7 @@ class MetadataHandler {
{ name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' },
{ name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' },
{ name: 'related', definition: 'https://raw.githubusercontent.com/HealthIntersections/FHIRsmith/refs/heads/main/tx/data/OperationDefinition-ValueSet-related.json' },
+ { name: 'cache-control', definition: 'http://hl7.org/fhir/tools/OperationDefinition/cache-control' },
{ name: 'versions', definition: 'http://hl7.org/fhir/OperationDefinition/fhir-versions' }
]
}
@@ -395,10 +396,14 @@ class MetadataHandler {
buildExpansionCapabilities() {
return {
parameter: [
- {
- name: 'cache-id',
- documentation: 'This server supports caching terminology resources between calls. Clients only need to send value sets and codesystems once; thereafter they are automatically in scope for calls with the same cache-id. The cache is retained for 30 min from last call'
- },
+ // NOTE: `cache-id` is deliberately not advertised as an expansion parameter.
+ // Caching is now discovered via the $cache-control operation in the
+ // CapabilityStatement, not by advertising a cache-id parameter here. The old
+ // advertisement implied the implicit "auto-create on first sight" protocol,
+ // which has been replaced: a cache must be created explicitly with
+ // $cache-control?mode=start before its cache-id can be used. Clients that
+ // only know the old mechanism will see no cache-id parameter and simply not
+ // cache (inlining each request), which is correct, just not optimised.
{
name: 'tx-resource',
documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded'
diff --git a/tx/workers/worker.js b/tx/workers/worker.js
index f9aa1bf5..ab28d134 100644
--- a/tx/workers/worker.js
+++ b/tx/workers/worker.js
@@ -8,6 +8,12 @@ const {Languages} = require("../../library/languages");
const {ConceptMap} = require("../library/conceptmap");
const {Renderer} = require("../library/renderer");
+// The cache-id travels as an HTTP header (not an operation parameter) so proxies /
+// load-balancers can act on it and the server can reject it before parsing the body.
+// Defined here (the base worker) so both the worker and cache-control share one
+// source of truth without a circular require. Express lower-cases header names.
+const CACHE_ID_HEADER = 'x-cache-id';
+
/**
* Custom error for terminology setup issues
*/
@@ -121,7 +127,11 @@ class TerminologyWorker {
latest = i;
}
}
- return matches[latest];
+ const found = matches[latest];
+ if (this.additionalResourcesCacheId && this.log) {
+ this.log.info(`cache-id '${this.additionalResourcesCacheId}': using cached ${found.resourceType} ${found.url}${found.version ? '|' + found.version : ''} for lookup of ${url}${version ? '|' + version : ''}`);
+ }
+ return found;
}
}
@@ -487,50 +497,75 @@ class TerminologyWorker {
* @returns {Object} Parameters resource
*/
buildParameters(req) {
+ let params;
+
// If POST with Parameters resource, use directly
if (req.method === 'POST' && req.body && req.body.resourceType === 'Parameters') {
- return req.body;
- }
- if (req.method === 'POST' && req.body && req.body.resourceType) {
+ params = req.body;
+ } else if (req.method === 'POST' && req.body && req.body.resourceType) {
let langs = this.languages.parse(req.headers['accept-language']);
throw new Issue('error', 'invalid', null, 'Wrong_type_for_resource_expected', this.i18n.translate('Wrong_type_for_resource_expected', langs, ["Parameters", req.body.resourceType])).handleAsOO(400);
- }
-
- // Convert query params or form body to Parameters
- const source = req.method === 'POST' ? {...req.query, ...req.body} : req.query;
- const params = {
- resourceType: 'Parameters',
- parameter: []
- };
+ } else {
+ // Convert query params or form body to Parameters
+ const source = req.method === 'POST' ? {...req.query, ...req.body} : req.query;
+ params = {
+ resourceType: 'Parameters',
+ parameter: []
+ };
- for (const [name, value] of Object.entries(source)) {
- if (value === undefined || value === null) continue;
+ for (const [name, value] of Object.entries(source)) {
+ if (value === undefined || value === null) continue;
- if (Array.isArray(value)) {
- // Repeating parameter
- for (const v of value) {
- params.parameter.push({name, valueString: String(v)});
- }
- } else if (typeof value === 'object') {
- // Could be a resource or complex type - check resourceType
- if (value.resourceType) {
- params.parameter.push({name, resource: value});
+ if (Array.isArray(value)) {
+ // Repeating parameter
+ for (const v of value) {
+ params.parameter.push({name, valueString: String(v)});
+ }
+ } else if (typeof value === 'object') {
+ // Could be a resource or complex type - check resourceType
+ if (value.resourceType) {
+ params.parameter.push({name, resource: value});
+ } else {
+ // Assume it's a complex type like Coding or CodeableConcept
+ params.parameter.push(this.buildComplexParameter(name, value));
+ }
+ } else if (value == 'true') {
+ params.parameter.push({name, valueBoolean: true});
+ } else if (value == 'false') {
+ params.parameter.push({name, valueBoolean: false});
} else {
- // Assume it's a complex type like Coding or CodeableConcept
- params.parameter.push(this.buildComplexParameter(name, value));
+ params.parameter.push({name, valueString: String(value)});
}
- } else if (value == 'true') {
- params.parameter.push({name, valueBoolean: true});
- } else if (value == 'false') {
- params.parameter.push({name, valueBoolean: false});
- } else {
- params.parameter.push({name, valueString: String(value)});
}
}
+ this.applyCacheIdHeader(req, params);
return params;
}
+ /**
+ * Normalise the cache-id from the `${CACHE_ID_HEADER}` header into a `cache-id`
+ * parameter, so all downstream code can read the cache-id the same way whether
+ * the client sent it as a header (the going-forward mechanism) or, for now, still
+ * as a parameter. If a cache-id parameter is already present it is left as-is, so
+ * an explicit parameter wins and there's no surprising override.
+ * @param {express.Request} req
+ * @param {Object} params - Parameters resource (mutated in place)
+ */
+ applyCacheIdHeader(req, params) {
+ const headerVal = req && req.headers ? req.headers[CACHE_ID_HEADER] : null;
+ if (!headerVal) {
+ return;
+ }
+ if (!params.parameter) {
+ params.parameter = [];
+ }
+ if (params.parameter.some(p => p.name === 'cache-id')) {
+ return;
+ }
+ params.parameter.push({ name: 'cache-id', valueId: String(headerVal) });
+ }
+
/**
* Build a parameter for complex types
*/
@@ -559,36 +594,79 @@ class TerminologyWorker {
// ========== Additional Resources Handling ==========
/**
- * Set up additional resources from tx-resource parameters and cache
+ * Collect the resources supplied inline in a Parameters resource: the
+ * `tx-resource` parameters, and the primary `valueSet`/`codeSystem` parameters.
+ *
+ * The primary resource is collected separately because the cache-id protocol
+ * needs it retained too: fhir-core sends the main ValueSet as `valueSet` (not
+ * `tx-resource`), so if it isn't cached, a later by-reference call can't resolve
+ * it ("value set ... could not be found"). A primary resource is only usable by
+ * reference if it has a url to key on, so url-less ones are skipped.
+ *
* @param {Object} params - Parameters resource
+ * @returns {{txResources: Array, primaryResources: Array}} wrapped resources
*/
- setupAdditionalResources(params) {
- if (!params || !params.parameter) return;
-
- // Collect tx-resource parameters (resources provided inline)
+ collectSuppliedResources(params) {
const txResources = [];
+ const primaryResources = [];
+ if (!params || !params.parameter) {
+ return { txResources, primaryResources };
+ }
for (const param of params.parameter) {
- this.deadCheck('setupAdditionalResources');
+ this.deadCheck('collectSuppliedResources');
if (param.name === 'tx-resource' && param.resource) {
- let res = this.wrapRawResource(param.resource);
+ const res = this.wrapRawResource(param.resource);
if (res) {
txResources.push(res);
}
+ } else if ((param.name === 'valueSet' || param.name === 'codeSystem') && param.resource && param.resource.url) {
+ const res = this.wrapRawResource(param.resource);
+ if (res) {
+ primaryResources.push(res);
+ }
}
}
+ return { txResources, primaryResources };
+ }
+
+ /**
+ * Set up additional resources from tx-resource parameters and cache
+ * @param {Object} params - Parameters resource
+ */
+ setupAdditionalResources(params) {
+ if (!params || !params.parameter) return;
+
+ // Collect the resources supplied inline on this request (tx-resource plus the
+ // primary valueSet/codeSystem). See collectSuppliedResources for why the
+ // primary resource is included.
+ const { txResources, primaryResources } = this.collectSuppliedResources(params);
// Check for cache-id
const cacheIdParam = this.findParameter(params, 'cache-id');
const cacheId = cacheIdParam ? this.getParameterValue(cacheIdParam) : null;
if (cacheId && this.opContext.resourceCache) {
- // Merge tx-resources with cached resources
- if (txResources.length > 0) {
- this.opContext.resourceCache.add(cacheId, txResources);
+ // The cache must already exist: caches are created explicitly via
+ // $cache-control?mode=start, which is the only thing that mints a cache-id.
+ // A cache-id the server doesn't know is an unambiguous, server-authoritative
+ // error condition (never created, or expired / released) - report it with a
+ // specific coded issue rather than silently auto-creating a fresh cache and
+ // then failing obscurely later when a by-reference resource can't be found.
+ if (!this.opContext.resourceCache.has(cacheId)) {
+ throw new Issue('error', 'not-found', null, 'CACHE_ID_UNKNOWN',
+ this.i18n.translate('CACHE_ID_UNKNOWN', this.opContext.langs, [cacheId]),
+ 'cache-id-unknown', 404);
+ }
+
+ // The cache exists: merge any resources supplied on this request into it
+ // (incremental population is allowed), then expose the full cache contents.
+ const toCache = txResources.concat(primaryResources);
+ if (toCache.length > 0) {
+ this.opContext.resourceCache.add(cacheId, toCache);
}
- // Set additional resources to all resources for this cache-id
this.additionalResources = this.opContext.resourceCache.get(cacheId);
+ this.additionalResourcesCacheId = cacheId;
} else {
// No cache-id, just use the tx-resources directly
this.additionalResources = txResources;
@@ -708,7 +786,7 @@ class TerminologyWorker {
// Check for various value types
const valueTypes = [
'valueString', 'valueCode', 'valueUri', 'valueCanonical', 'valueUrl',
- 'valueBoolean', 'valueInteger', 'valueDecimal',
+ 'valueBoolean', 'valueInteger', 'valueDecimal', 'valueId',
'valueDateTime', 'valueDate', 'valueTime',
'valueCoding', 'valueCodeableConcept',
'valueIdentifier', 'valueQuantity'
@@ -934,5 +1012,6 @@ class TerminologyWorker {
module.exports = {
TerminologyWorker,
- TerminologySetupError
+ TerminologySetupError,
+ CACHE_ID_HEADER
};
\ No newline at end of file