fhirsmith 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/operation-context.js
CHANGED
|
@@ -26,6 +26,30 @@ function debugLog(error, message) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Render a duration for a human reading an error message: coarse enough to read
|
|
31
|
+
* at a glance, precise enough to compare against a configured timeout.
|
|
32
|
+
* @param {number} ms - duration in milliseconds
|
|
33
|
+
* @returns {string} e.g. "45 seconds", "31 minutes", "2 hours 5 minutes"
|
|
34
|
+
*/
|
|
35
|
+
function formatDuration(ms) {
|
|
36
|
+
if (ms === null || ms === undefined || !isFinite(ms) || ms < 0) {
|
|
37
|
+
return 'an unknown time';
|
|
38
|
+
}
|
|
39
|
+
const seconds = Math.round(ms / 1000);
|
|
40
|
+
if (seconds < 90) {
|
|
41
|
+
return `${seconds} second${seconds === 1 ? '' : 's'}`;
|
|
42
|
+
}
|
|
43
|
+
const minutes = Math.round(seconds / 60);
|
|
44
|
+
if (minutes < 90) {
|
|
45
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
46
|
+
}
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
const rem = minutes % 60;
|
|
49
|
+
const h = `${hours} hour${hours === 1 ? '' : 's'}`;
|
|
50
|
+
return rem === 0 ? h : `${h} ${rem} minute${rem === 1 ? '' : 's'}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
|
|
30
54
|
class TimeTracker {
|
|
31
55
|
constructor() {
|
|
@@ -56,7 +80,9 @@ class TimeTracker {
|
|
|
56
80
|
* Stores resources by cache-id for reuse across requests
|
|
57
81
|
*/
|
|
58
82
|
class ResourceCache {
|
|
59
|
-
|
|
83
|
+
logCacheOps = false;
|
|
84
|
+
|
|
85
|
+
constructor(stats, maxTombstones = 1000) {
|
|
60
86
|
this.stats = stats;
|
|
61
87
|
this.cache = new Map();
|
|
62
88
|
this.locks = new Map(); // For thread-safety with async operations
|
|
@@ -70,6 +96,156 @@ class ResourceCache {
|
|
|
70
96
|
// this cache has held at once, for capacity reporting.
|
|
71
97
|
this.maxConcepts = 0;
|
|
72
98
|
this.maxSizeValue = 0;
|
|
99
|
+
// Tombstones: why a cache-id that this server *did* issue is no longer here.
|
|
100
|
+
// Without these, an unknown cache-id is indistinguishable from one that was
|
|
101
|
+
// never issued, and the client can't tell "your build released it early" from
|
|
102
|
+
// "it sat idle too long" from "you're talking to the wrong server/instance" -
|
|
103
|
+
// which is exactly the ambiguity that makes cache-id-unknown reports (see
|
|
104
|
+
// issue #279) unfalsifiable. Bounded and insertion-ordered: oldest evicted
|
|
105
|
+
// first, so this can never grow without limit on a long-running server.
|
|
106
|
+
this.tombstones = new Map(); // cacheId -> { reason, at, createdAt, lastUsed, idleMs, maxAgeMs }
|
|
107
|
+
this.maxTombstones = maxTombstones;
|
|
108
|
+
// The idle timeout this cache is pruned against, in ms. The cache doesn't
|
|
109
|
+
// enforce it (prune() is told the maxAge by its caller), it just reports it,
|
|
110
|
+
// so a client polling $cache-control?mode=check can work out how often it needs
|
|
111
|
+
// to poll instead of guessing. Null = not advertised.
|
|
112
|
+
this.idleTimeoutMs = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Tell the cache what idle timeout it is being pruned against, so it can report
|
|
117
|
+
* it to clients. Purely informational - pruning is driven by prune()'s argument.
|
|
118
|
+
* @param {number} ms
|
|
119
|
+
*/
|
|
120
|
+
setIdleTimeout(ms) {
|
|
121
|
+
this.idleTimeoutMs = ms;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record why a cache-id stopped existing, so a later request carrying that id
|
|
126
|
+
* can be told what actually happened to it rather than a list of maybes.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} cacheId - The cache identifier
|
|
129
|
+
* @param {Object} entry - The entry being removed (for its timings)
|
|
130
|
+
* @param {'closed'|'expired'|'cleared'} reason - How it went away:
|
|
131
|
+
* `closed` = the client released it with $cache-control?mode=end;
|
|
132
|
+
* `expired` = it went unused for longer than the idle timeout;
|
|
133
|
+
* `cleared` = the server dropped every cache at once.
|
|
134
|
+
* @param {Object} [extra] - Reason-specific detail (e.g. idleMs, maxAgeMs)
|
|
135
|
+
*/
|
|
136
|
+
_recordTombstone(cacheId, entry, reason, extra = {}) {
|
|
137
|
+
// Re-inserting moves it to the end of the insertion order, so an id that
|
|
138
|
+
// died twice (created, expired, re-issued, closed) reports its latest fate.
|
|
139
|
+
this.tombstones.delete(cacheId);
|
|
140
|
+
this.tombstones.set(cacheId, {
|
|
141
|
+
reason,
|
|
142
|
+
at: Date.now(),
|
|
143
|
+
createdAt: entry ? entry.createdAt : null,
|
|
144
|
+
lastUsed: entry ? entry.lastUsed : null,
|
|
145
|
+
...extra
|
|
146
|
+
});
|
|
147
|
+
while (this.tombstones.size > this.maxTombstones) {
|
|
148
|
+
// Map iterates in insertion order, so the first key is the oldest record.
|
|
149
|
+
this.tombstones.delete(this.tombstones.keys().next().value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What happened to a cache-id that isn't in the cache. Null means this server
|
|
155
|
+
* has no record of ever issuing it: either it never did, or the id was issued
|
|
156
|
+
* by a different server/instance/endpoint, or this process has restarted since.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} cacheId - The cache identifier
|
|
159
|
+
* @returns {Object|null} the tombstone record, or null
|
|
160
|
+
*/
|
|
161
|
+
tombstone(cacheId) {
|
|
162
|
+
return this.tombstones.get(cacheId) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The message to report for a cache-id that isn't here: which of the three
|
|
167
|
+
* fates it met, with the numbers that make it checkable. Both throw sites
|
|
168
|
+
* (worker.setupAdditionalResources and batchValidate.frontLoadBatch) use this
|
|
169
|
+
* so they can never drift apart.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} cacheId - The cache identifier
|
|
172
|
+
* @returns {{messageId: string, params: Array}} message id and its parameters,
|
|
173
|
+
* which always start with the cache-id itself.
|
|
174
|
+
*/
|
|
175
|
+
describeMissing(cacheId) {
|
|
176
|
+
const t = this.tombstone(cacheId);
|
|
177
|
+
if (!t) {
|
|
178
|
+
return { messageId: 'CACHE_ID_UNKNOWN', params: [cacheId] };
|
|
179
|
+
}
|
|
180
|
+
const ago = formatDuration(Date.now() - t.at);
|
|
181
|
+
switch (t.reason) {
|
|
182
|
+
case 'closed':
|
|
183
|
+
return { messageId: 'CACHE_ID_CLOSED', params: [cacheId, ago, formatDuration(t.at - t.createdAt)] };
|
|
184
|
+
case 'expired':
|
|
185
|
+
return {
|
|
186
|
+
messageId: 'CACHE_ID_EXPIRED',
|
|
187
|
+
params: [cacheId, formatDuration(t.idleMs), formatDuration(t.maxAgeMs), ago]
|
|
188
|
+
};
|
|
189
|
+
default:
|
|
190
|
+
return { messageId: 'CACHE_ID_CLEARED', params: [cacheId, ago] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Number of dead cache-ids this cache still remembers the fate of.
|
|
196
|
+
* @returns {number}
|
|
197
|
+
*/
|
|
198
|
+
tombstoneCount() {
|
|
199
|
+
return this.tombstones.size;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Report on a cache-id without touching it. Deliberately read-only, and
|
|
204
|
+
* deliberately separate from touch(): a client asking "is my cache still alive?"
|
|
205
|
+
* wants to know how idle it had become, and reading after a refresh would always
|
|
206
|
+
* answer zero. Callers that are keeping the cache alive read the status first,
|
|
207
|
+
* then touch.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} cacheId - The cache identifier
|
|
210
|
+
* @returns {Object} `{exists: false}` for a cache-id that isn't here, otherwise
|
|
211
|
+
* `{exists: true, sealed, resources, concepts, idleMs, ageMs, timeoutMs}`.
|
|
212
|
+
*/
|
|
213
|
+
status(cacheId) {
|
|
214
|
+
const entry = this.cache.get(cacheId);
|
|
215
|
+
if (!entry) {
|
|
216
|
+
return { exists: false };
|
|
217
|
+
}
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
return {
|
|
220
|
+
exists: true,
|
|
221
|
+
sealed: !!entry.sealed,
|
|
222
|
+
resources: entry.resources.length,
|
|
223
|
+
concepts: entry.concepts || 0,
|
|
224
|
+
idleMs: now - entry.lastUsed,
|
|
225
|
+
ageMs: entry.createdAt ? now - entry.createdAt : null,
|
|
226
|
+
timeoutMs: this.idleTimeoutMs
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reset a cache's idle clock without reading or changing its contents - the
|
|
232
|
+
* keepalive behind $cache-control?mode=check. A client whose traffic is absorbed
|
|
233
|
+
* by its own local cache can otherwise let a cache it is still relying on go idle
|
|
234
|
+
* past the timeout, and only find out when it finally needs the server again.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} cacheId - The cache identifier
|
|
237
|
+
* @returns {boolean} true if the cache existed and was refreshed
|
|
238
|
+
*/
|
|
239
|
+
touch(cacheId) {
|
|
240
|
+
const entry = this.cache.get(cacheId);
|
|
241
|
+
if (!entry) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
entry.lastUsed = Date.now();
|
|
245
|
+
if (this.logCacheOps) {
|
|
246
|
+
this.log.info(`cache-id '${cacheId}': touched (idle clock reset)`);
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
73
249
|
}
|
|
74
250
|
|
|
75
251
|
/**
|
|
@@ -102,10 +278,14 @@ class ResourceCache {
|
|
|
102
278
|
const entry = this.cache.get(cacheId);
|
|
103
279
|
if (entry) {
|
|
104
280
|
entry.lastUsed = Date.now();
|
|
105
|
-
|
|
281
|
+
if (this.logCacheOps) {
|
|
282
|
+
this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`);
|
|
283
|
+
}
|
|
106
284
|
return [...entry.resources]; // Return a copy
|
|
107
285
|
}
|
|
108
|
-
this.
|
|
286
|
+
if (this.logCacheOps) {
|
|
287
|
+
this.log.info(`cache-id '${cacheId}': miss (no entry)`);
|
|
288
|
+
}
|
|
109
289
|
return [];
|
|
110
290
|
}
|
|
111
291
|
|
|
@@ -151,7 +331,7 @@ class ResourceCache {
|
|
|
151
331
|
}
|
|
152
332
|
}
|
|
153
333
|
|
|
154
|
-
const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 };
|
|
334
|
+
const entry = this.cache.get(cacheId) || { resources: [], createdAt: Date.now(), lastUsed: Date.now(), concepts: 0 };
|
|
155
335
|
|
|
156
336
|
// Merge resources, avoiding duplicates by url+version. Keep the entry's concept
|
|
157
337
|
// subtotal and the cache-wide total in step with each insertion/replacement.
|
|
@@ -165,12 +345,16 @@ class ResourceCache {
|
|
|
165
345
|
entry.resources[existingIndex] = resource;
|
|
166
346
|
entry.concepts += delta;
|
|
167
347
|
this.totalConcepts += delta;
|
|
168
|
-
this.
|
|
348
|
+
if (this.logCacheOps) {
|
|
349
|
+
this.log.info(`cache-id '${cacheId}': replaced ${key}`);
|
|
350
|
+
}
|
|
169
351
|
} else {
|
|
170
352
|
entry.resources.push(resource);
|
|
171
353
|
entry.concepts += newConcepts;
|
|
172
354
|
this.totalConcepts += newConcepts;
|
|
173
|
-
this.
|
|
355
|
+
if (this.logCacheOps) {
|
|
356
|
+
this.log.info(`cache-id '${cacheId}': added ${key}`);
|
|
357
|
+
}
|
|
174
358
|
}
|
|
175
359
|
}
|
|
176
360
|
|
|
@@ -187,7 +371,9 @@ class ResourceCache {
|
|
|
187
371
|
* resources and will not grow when further resources are seen later.
|
|
188
372
|
*/
|
|
189
373
|
set(cacheId, resources, sealed = false) {
|
|
190
|
-
|
|
374
|
+
if (this.logCacheOps) {
|
|
375
|
+
this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
|
|
376
|
+
}
|
|
191
377
|
// Retained under a cache-id - mark cached so providers may memoise resolved
|
|
192
378
|
// filter analysis on their filter elements (see filter()/fc).
|
|
193
379
|
for (const resource of resources) {
|
|
@@ -209,21 +395,32 @@ class ResourceCache {
|
|
|
209
395
|
this.totalConcepts += concepts;
|
|
210
396
|
this.cache.set(cacheId, {
|
|
211
397
|
resources: [...resources],
|
|
398
|
+
createdAt: existing ? existing.createdAt : Date.now(),
|
|
212
399
|
lastUsed: Date.now(),
|
|
213
400
|
concepts,
|
|
214
401
|
sealed: !!sealed
|
|
215
402
|
});
|
|
403
|
+
// This id is alive again, so any record of its previous death is now wrong.
|
|
404
|
+
this.tombstones.delete(cacheId);
|
|
216
405
|
this._trackMax();
|
|
217
406
|
}
|
|
218
407
|
|
|
219
408
|
/**
|
|
220
|
-
* Clear a specific cache-id
|
|
409
|
+
* Clear a specific cache-id. This is the client-initiated release path
|
|
410
|
+
* ($cache-control?mode=end), so a cache that existed is tombstoned as `closed`:
|
|
411
|
+
* a later request still carrying the id gets told the client let it go, rather
|
|
412
|
+
* than being sent looking for an expiry that never happened.
|
|
413
|
+
*
|
|
221
414
|
* @param {string} cacheId - The cache identifier
|
|
222
415
|
*/
|
|
223
416
|
clear(cacheId) {
|
|
224
417
|
const entry = this.cache.get(cacheId);
|
|
225
418
|
if (entry) {
|
|
226
419
|
this.totalConcepts -= entry.concepts || 0;
|
|
420
|
+
this._recordTombstone(cacheId, entry, 'closed');
|
|
421
|
+
if (this.logCacheOps) {
|
|
422
|
+
this.log.info(`cache-id '${cacheId}': closed by client after ${formatDuration(Date.now() - entry.createdAt)} (held ${entry.resources.length} resource(s))`);
|
|
423
|
+
}
|
|
227
424
|
}
|
|
228
425
|
this.cache.delete(cacheId);
|
|
229
426
|
}
|
|
@@ -232,6 +429,9 @@ class ResourceCache {
|
|
|
232
429
|
* Clear all cached entries
|
|
233
430
|
*/
|
|
234
431
|
clearAll() {
|
|
432
|
+
for (const [cacheId, entry] of this.cache.entries()) {
|
|
433
|
+
this._recordTombstone(cacheId, entry, 'cleared');
|
|
434
|
+
}
|
|
235
435
|
this.cache.clear();
|
|
236
436
|
this.totalConcepts = 0;
|
|
237
437
|
}
|
|
@@ -247,9 +447,18 @@ class ResourceCache {
|
|
|
247
447
|
let i = 0;
|
|
248
448
|
const now = Date.now();
|
|
249
449
|
for (const [cacheId, entry] of this.cache.entries()) {
|
|
250
|
-
|
|
450
|
+
const idleMs = now - entry.lastUsed;
|
|
451
|
+
if (idleMs > maxAge) {
|
|
251
452
|
i++;
|
|
252
453
|
this.totalConcepts -= entry.concepts || 0;
|
|
454
|
+
// Record the *measured* idle time, not the configured timeout: the sweep
|
|
455
|
+
// runs periodically, so a cache can sit dead for up to one sweep interval
|
|
456
|
+
// past the timeout, and only the measured figure lets a client line the
|
|
457
|
+
// expiry up against its own request log.
|
|
458
|
+
this._recordTombstone(cacheId, entry, 'expired', { idleMs, maxAgeMs: maxAge });
|
|
459
|
+
if (this.logCacheOps) {
|
|
460
|
+
this.log.info(`cache-id '${cacheId}': expired after ${formatDuration(idleMs)} idle (timeout ${formatDuration(maxAge)})`);
|
|
461
|
+
}
|
|
253
462
|
this.cache.delete(cacheId);
|
|
254
463
|
}
|
|
255
464
|
}
|
|
@@ -906,5 +1115,6 @@ module.exports = {
|
|
|
906
1115
|
ResourceCache,
|
|
907
1116
|
ExpansionCache,
|
|
908
1117
|
isDebugging,
|
|
909
|
-
debugLog
|
|
1118
|
+
debugLog,
|
|
1119
|
+
formatDuration
|
|
910
1120
|
};
|