fhirsmith 0.11.2 → 0.12.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/stats-db.js ADDED
@@ -0,0 +1,127 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const Database = require('better-sqlite3');
4
+ const folders = require('./library/folder-setup');
5
+
6
+ /**
7
+ * Persistent storage for the server's request statistics (issue #255).
8
+ *
9
+ * The in-memory counters in ServerStats reset every time the server restarts,
10
+ * which makes them useless for anything but "what has happened since the last
11
+ * deploy". This class writes them to a SQLite database that outlives restarts
12
+ * and upgrades.
13
+ *
14
+ * One row per (module, endpoint, operation) per collection interval, holding
15
+ * both numbers:
16
+ *
17
+ * - the *delta*: what happened in that interval. This is what you graph -
18
+ * plot count_delta against time and you have requests per interval without
19
+ * any differencing at the query end.
20
+ * - the *total*: the all-time cumulative count, carried across restarts by
21
+ * reloading it at startup. This is what you quote as "how much has this
22
+ * endpoint ever been asked to do", and it means a restart shows up as a
23
+ * flat spot in the graph rather than a cliff back to zero.
24
+ *
25
+ * Rows are only written for keys that saw traffic in the interval, so idle
26
+ * endpoints cost nothing. Nothing is pruned: at 10 minute intervals a
27
+ * continuously busy (endpoint, operation) pair produces ~52,000 rows a year,
28
+ * which SQLite does not notice.
29
+ *
30
+ * better-sqlite3 (rather than sqlite3) on purpose: the writes are synchronous,
31
+ * so the final flush in ServerStats.finishStats() lands on disk before
32
+ * server.js calls process.exit(). A handful of inserts every ten minutes is
33
+ * well under a millisecond, so this does not put the event loop at risk.
34
+ */
35
+ class StatsDatabase {
36
+
37
+ constructor(config, log) {
38
+ this.log = log || console;
39
+ this.config = config || {};
40
+ this.db = null;
41
+ this.path = StatsDatabase.resolvePath(this.config);
42
+ }
43
+
44
+ /**
45
+ * Where the database lives: config.database if given (relative paths resolve
46
+ * under the data folder's databases directory), otherwise stats.db there.
47
+ */
48
+ static resolvePath(config) {
49
+ const configured = config && config.database;
50
+ if (!configured) {
51
+ return path.join(folders.databasesDir(), 'stats.db');
52
+ }
53
+ return path.isAbsolute(configured) ? configured : path.join(folders.databasesDir(), configured);
54
+ }
55
+
56
+ /**
57
+ * Open the database, creating the file and the schema if they aren't there.
58
+ */
59
+ open() {
60
+ fs.mkdirSync(path.dirname(this.path), {recursive: true});
61
+ this.db = new Database(this.path);
62
+ this.db.pragma('journal_mode = WAL');
63
+ this.db.exec(`
64
+ CREATE TABLE IF NOT EXISTS request_counts (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ time INTEGER NOT NULL,
67
+ interval_ms INTEGER NOT NULL,
68
+ module TEXT NOT NULL,
69
+ endpoint TEXT NOT NULL,
70
+ operation TEXT NOT NULL,
71
+ count_delta INTEGER NOT NULL,
72
+ count_total INTEGER NOT NULL,
73
+ time_delta INTEGER NOT NULL,
74
+ time_total INTEGER NOT NULL
75
+ )
76
+ `);
77
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
78
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
79
+
80
+ this.insertStatement = this.db.prepare(`
81
+ INSERT INTO request_counts
82
+ (time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ `);
85
+ this.insertAll = this.db.transaction((time, intervalMs, rows) => {
86
+ for (const row of rows) {
87
+ this.insertStatement.run(time, intervalMs, row.module, row.endpoint, row.operation,
88
+ row.countDelta, row.countTotal, row.timeDelta, row.timeTotal);
89
+ }
90
+ });
91
+ return this;
92
+ }
93
+
94
+ /**
95
+ * The most recent cumulative total for each key, so counting resumes where
96
+ * the last run left off instead of starting again from zero.
97
+ */
98
+ loadTotals() {
99
+ if (!this.db) {
100
+ return [];
101
+ }
102
+ return this.db.prepare(`
103
+ SELECT module, endpoint, operation, count_total AS countTotal, time_total AS timeTotal
104
+ FROM request_counts
105
+ WHERE id IN (SELECT MAX(id) FROM request_counts GROUP BY module, endpoint, operation)
106
+ `).all();
107
+ }
108
+
109
+ /**
110
+ * Write one interval's worth of rows. All or nothing - if the transaction
111
+ * fails the caller keeps its deltas and tries again next interval.
112
+ */
113
+ write(time, intervalMs, rows) {
114
+ if (this.db && rows && rows.length > 0) {
115
+ this.insertAll(time, intervalMs, rows);
116
+ }
117
+ }
118
+
119
+ close() {
120
+ if (this.db) {
121
+ this.db.close();
122
+ this.db = null;
123
+ }
124
+ }
125
+ }
126
+
127
+ module.exports = StatsDatabase;
package/stats.js CHANGED
@@ -1,6 +1,49 @@
1
1
  const { monitorEventLoopDelay } = require('perf_hooks');
2
2
  const {Utilities} = require("./library/utilities");
3
3
  const escape = require('escape-html');
4
+ const StatsDatabase = require('./stats-db');
5
+
6
+ // Requests that aren't counted by a module (the root page, the dashboard) are
7
+ // attributed here, and requests that have no endpoint dimension (everything
8
+ // outside the tx module) carry NO_ENDPOINT.
9
+ const SERVER_MODULE = '(server)';
10
+ const NO_ENDPOINT = '';
11
+
12
+ /**
13
+ * A per-module view of the server statistics.
14
+ *
15
+ * Modules are handed one of these instead of the ServerStats itself, so that
16
+ * everything they count is attributed to them without each of the ~76 call
17
+ * sites having to name its own module. The API is deliberately the same shape
18
+ * as ServerStats, so a module can't tell the difference - except that
19
+ * countRequest takes an optional endpoint, which is how the tx module
20
+ * distinguishes /tx/r4 from /tx/r5.
21
+ */
22
+ class ModuleStats {
23
+
24
+ constructor(parent, module) {
25
+ this.parent = parent;
26
+ this.module = module;
27
+ }
28
+
29
+ countRequest(name, tat, endpoint = NO_ENDPOINT) {
30
+ this.parent.countRequest(name, tat, this.module, endpoint);
31
+ }
32
+
33
+ addTask(name, frequency) { this.parent.addTask(name, frequency); }
34
+ task(name, state) { this.parent.task(name, state); }
35
+ taskDone(name, state) { this.parent.taskDone(name, state); }
36
+ taskError(name, state) { this.parent.taskError(name, state); }
37
+
38
+ // Pass-throughs for the handful of properties modules read off the stats
39
+ // object. These have to be getters, not copies, or a module would capture
40
+ // the value at construction time and never see it change.
41
+ get cachingModules() { return this.parent.cachingModules; }
42
+ get requestCount() { return this.parent.requestCount; }
43
+ get staticRequestCount() { return this.parent.staticRequestCount; }
44
+ get history() { return this.parent.history; }
45
+ get startTime() { return this.parent.startTime; }
46
+ }
4
47
 
5
48
  class ServerStats {
6
49
  started = false;
@@ -16,13 +59,53 @@ class ServerStats {
16
59
  timer;
17
60
  cachingModules = [];
18
61
  taskMap = new Map();
62
+ // key (module/endpoint/operation) -> counter. See countRequest().
63
+ counters = new Map();
64
+ db = null;
19
65
 
20
- constructor() {
66
+ constructor(config, log) {
67
+ this.config = config || {};
68
+ this.log = log || console;
69
+ if (this.config.intervalMinutes) {
70
+ this.intervalMs = this.config.intervalMinutes * 60 * 1000;
71
+ }
72
+ this.openDatabase();
21
73
  this.timer = setInterval(() => {
22
74
  this.recordMetrics();
23
75
  }, this.intervalMs);
24
76
  }
25
77
 
78
+ /**
79
+ * Open the persistent counter store and pick up where the last run of the
80
+ * server left off. Statistics must never be the reason the server won't
81
+ * start, so a failure here is logged and then ignored: the counters carry
82
+ * on in memory, they just don't survive the next restart.
83
+ */
84
+ openDatabase() {
85
+ if (this.config.enabled === false) {
86
+ return;
87
+ }
88
+ try {
89
+ this.db = new StatsDatabase(this.config, this.log).open();
90
+ for (const row of this.db.loadTotals()) {
91
+ const counter = this.counterFor(row.module, row.endpoint, row.operation);
92
+ counter.countTotal = row.countTotal;
93
+ counter.timeTotal = row.timeTotal;
94
+ }
95
+ } catch (e) {
96
+ this.log.error(`Unable to open the statistics database: ${e.message}. Statistics will not be persisted`);
97
+ this.db = null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * A view of the statistics that attributes everything counted through it to
103
+ * the named module (see ModuleStats).
104
+ */
105
+ forModule(module) {
106
+ return new ModuleStats(this, module);
107
+ }
108
+
26
109
  recordMetrics() {
27
110
  if (this.started) {
28
111
  const now = Date.now();
@@ -61,9 +144,12 @@ class ServerStats {
61
144
  this.requestTime = 0;
62
145
  this.lastTime = now;
63
146
 
64
- // Prune old data (keep 24 hours)
147
+ // Prune old data (keep 24 hours). Only the in-memory history is pruned -
148
+ // the per-endpoint counters are persisted and kept indefinitely.
65
149
  const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
66
150
  this.history = this.history.filter(m => m.time > cutoff);
151
+
152
+ this.flushCounters(now, this.intervalMs);
67
153
  }
68
154
  }
69
155
 
@@ -77,11 +163,78 @@ class ServerStats {
77
163
  this.recordMetrics();
78
164
  }
79
165
 
80
- countRequest(name, tat) {
81
- // we ignore name for now, but we might split the tat tracking up by name
82
- // at some stage
166
+ /**
167
+ * The counter for one (module, endpoint, operation), created on first use.
168
+ *
169
+ * countDelta/timeDelta accumulate within the current interval and are reset
170
+ * each time they're written out; countTotal/timeTotal are cumulative for the
171
+ * life of the database, not the life of the process.
172
+ */
173
+ counterFor(module, endpoint, operation) {
174
+ const key = `${module}\u001f${endpoint}\u001f${operation}`;
175
+ let counter = this.counters.get(key);
176
+ if (!counter) {
177
+ counter = {
178
+ module: module, endpoint: endpoint, operation: operation,
179
+ countDelta: 0, timeDelta: 0, countTotal: 0, timeTotal: 0
180
+ };
181
+ this.counters.set(key, counter);
182
+ }
183
+ return counter;
184
+ }
185
+
186
+ /**
187
+ * Count one request. Modules go through their own ModuleStats, which fills
188
+ * in module (and, for tx, the endpoint) for them.
189
+ *
190
+ * @param {string} name - the operation, e.g. '$expand' or 'login'
191
+ * @param {number} tat - turnaround time in ms
192
+ * @param {string} module - the module that served it
193
+ * @param {string} endpoint - the endpoint within the module, where the
194
+ * module has more than one (the tx module does; nothing else does yet)
195
+ */
196
+ countRequest(name, tat, module = SERVER_MODULE, endpoint = NO_ENDPOINT) {
83
197
  this.requestCount++;
84
198
  this.requestTime = this.requestTime + tat;
199
+
200
+ const counter = this.counterFor(module || SERVER_MODULE, endpoint || NO_ENDPOINT, name || '(unnamed)');
201
+ counter.countDelta++;
202
+ counter.countTotal++;
203
+ counter.timeDelta = counter.timeDelta + tat;
204
+ counter.timeTotal = counter.timeTotal + tat;
205
+ }
206
+
207
+ /**
208
+ * Write out the counters that saw traffic this interval, and start the next
209
+ * one. The deltas are only cleared if the write succeeded, so a transient
210
+ * database problem delays the numbers rather than losing them (the interval
211
+ * they're eventually written against is longer, which is what interval_ms on
212
+ * the row is for).
213
+ */
214
+ flushCounters(now, intervalMs) {
215
+ if (!this.db) {
216
+ return 0;
217
+ }
218
+ const rows = [];
219
+ for (const counter of this.counters.values()) {
220
+ if (counter.countDelta > 0) {
221
+ rows.push({...counter});
222
+ }
223
+ }
224
+ if (rows.length === 0) {
225
+ return 0;
226
+ }
227
+ try {
228
+ this.db.write(now, intervalMs, rows);
229
+ } catch (e) {
230
+ this.log.error(`Unable to write statistics: ${e.message}`);
231
+ return 0;
232
+ }
233
+ for (const counter of this.counters.values()) {
234
+ counter.countDelta = 0;
235
+ counter.timeDelta = 0;
236
+ }
237
+ return rows.length;
85
238
  }
86
239
 
87
240
  addTask(name, frequency) {
@@ -144,6 +297,17 @@ class ServerStats {
144
297
 
145
298
  finishStats() {
146
299
  clearInterval(this.timer);
300
+ // Synchronous, so the last partial interval is on disk before server.js
301
+ // calls process.exit().
302
+ this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
303
+ if (this.db) {
304
+ try {
305
+ this.db.close();
306
+ } catch (e) {
307
+ this.log.error(`Unable to close the statistics database: ${e.message}`);
308
+ }
309
+ this.db = null;
310
+ }
147
311
  }
148
312
 
149
313
  // Live cache aggregates across all registered caching modules (defensive: a
@@ -174,4 +338,6 @@ class ServerStats {
174
338
  }
175
339
  }
176
340
  }
177
- module.exports = ServerStats;
341
+ module.exports = ServerStats;
342
+ module.exports.ModuleStats = ModuleStats;
343
+ module.exports.SERVER_MODULE = SERVER_MODULE;
@@ -1101,7 +1101,10 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
1101
1101
  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
1102
1102
  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
1103
1103
  Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
1104
- 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
1104
+ CACHE_ID_UNKNOWN = The cache ''{0}'' was never issued by this server, and this server has no record of it ever existing. Caches are created with $cache-control?mode=start, and a cache-id is only valid on the server instance and endpoint that issued it - either it was issued somewhere else, or this server has restarted since it was issued
1105
+ CACHE_ID_CLOSED = The cache ''{0}'' was closed {1} ago by the client, using $cache-control?mode=end (it had been open for {2}). It did not expire and it was not idle: something in the client released this cache while this request was still using it
1106
+ CACHE_ID_EXPIRED = The cache ''{0}'' expired: it went {1} without being used, and this server closes caches that have been idle for more than {2} (this one was dropped {3} ago). Every request that uses a cache resets its idle clock, so a client that keeps using a cache will not see this
1107
+ CACHE_ID_CLEARED = The cache ''{0}'' was dropped {1} ago, when this server cleared all of its caches
1105
1108
  Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
1106
1109
  Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
1107
1110
  Undefined_element_ = Undefined element ''{0}'' at {1}
package/tx/cs/cs-api.md CHANGED
@@ -105,7 +105,7 @@ All are `async`.
105
105
  | `parent(code)` | `null` | The parent concept, for hierarchical code systems. |
106
106
  | `extensions(code)` | `null` | FHIR extensions on the concept, if any. |
107
107
  | `properties(code)` | `[]` | The defined properties for the concept. |
108
- | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete (used by SCT). |
108
+ | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete. No provider overrides this: SNOMED expressions are checked against the concept model, and the only things it cannot check are ones SNOMED itself publishes no rule for. |
109
109
  | `sameConcept(a, b)` | `false` | Returns `true` if `a` and `b` refer to the same concept (e.g. different expression forms). |
110
110
  | `isDisplay(designation)` | `false` | Called for designations not already marked with a standard display use code; return `true` to treat the designation as a display. |
111
111
  | `subsumesTest(codeA, codeB)` | `'not-subsumed'` | Returns one of `equivalent`, `subsumes`, `subsumed-by`, or `not-subsumed`. |
@@ -148,6 +148,58 @@ class SnomedServices {
148
148
  refSetIndex: this.refSetIndex
149
149
  }, this.isAIndex);
150
150
 
151
+ // The expression services check the MRCM attribute ranges, but those are ECL
152
+ // and the ECL engine lives here, so hand them an evaluator. Returning null
153
+ // means "could not decide", and leaves the value unchecked.
154
+ this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
155
+ this._mrcmRangeCache = new Map();
156
+ }
157
+
158
+ /**
159
+ * Does this refinement value satisfy an MRCM range constraint?
160
+ *
161
+ * @param {string} constraint - the rangeConstraint ECL, e.g. "<< 182353008 |Side|"
162
+ * @param {SnomedExpression} value - the refinement's value expression
163
+ * @returns {boolean|null} null when the constraint cannot be evaluated
164
+ */
165
+ mrcmRangeSatisfied(constraint, value) {
166
+ let entry = this._mrcmRangeCache.get(constraint);
167
+ if (entry === undefined) {
168
+ entry = null;
169
+ try {
170
+ const ast = new ECLParser(new ECLLexer(constraint).tokenize()).parse();
171
+ const ctxt = this._evalECLNode(ast, null);
172
+ entry = {
173
+ ast: ast,
174
+ wildcard: !!ctxt.eclWildcard,
175
+ members: new Set(this._eclToIndexArray(ctxt))
176
+ };
177
+ } catch (error) {
178
+ // Concrete value ranges (dec/int/str) and anything else the ECL engine
179
+ // does not support: remembered as unusable so it is parsed only once.
180
+ entry = null;
181
+ }
182
+ this._mrcmRangeCache.set(constraint, entry);
183
+ }
184
+ if (!entry) {
185
+ return null;
186
+ }
187
+ if (entry.wildcard) {
188
+ return true;
189
+ }
190
+
191
+ const concepts = (value.concepts || []).filter(c => c.reference != null && c.reference !== NO_REFERENCE);
192
+ const refined = value.hasRefinements() || value.hasRefinementGroups();
193
+ if (!refined) {
194
+ if (concepts.length === 0) {
195
+ return null; // a concrete value, or nothing we can resolve
196
+ }
197
+ // Every focus concept of a plain value must be in the constraint's set.
198
+ return concepts.every(c => entry.members.has(c.reference));
199
+ }
200
+ // A refined value: ask the ECL engine whether the expression satisfies the
201
+ // constraint (it knows that a refinement of X is still << X).
202
+ return this._eclExpressionSatisfies({ expression: value }, entry.ast, null);
151
203
  }
152
204
 
153
205
  close() {
@@ -664,7 +716,7 @@ class SnomedServices {
664
716
  // we actually need the full concept list, otherwise filterSize returns 0
665
717
  // and the iteration yields nothing. Materialise active concepts now.
666
718
  if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
667
- result.descendants = this._eclEnumerateActiveConcepts(opContext);
719
+ result.descendants = this._eclEnumerateAllConcepts(opContext);
668
720
  delete result.eclWildcard;
669
721
  // The materialised wildcard form differs from the lazy (eclWildcard) form,
670
722
  // so mark it: the filter cache must not share it across iteration modes.
@@ -677,31 +729,29 @@ class SnomedServices {
677
729
  };
678
730
 
679
731
  /**
680
- * Return every active concept's index. Used to materialise wildcard results
681
- * when the filter needs to be iterated over (e.g. $expand).
732
+ * Return every concept's index. Used to materialise wildcard results when the
733
+ * filter needs to be iterated over (e.g. $expand).
734
+ *
735
+ * `*` means every concept the server knows, so this deliberately walks the
736
+ * concept table rather than descending from 138875005 |SNOMED CT Concept|.
737
+ * Two kinds of concept are in the table but not reachable from that root:
738
+ * inactive concepts (their is-a relationships are inactive too), and the
739
+ * foundation-metadata concepts that a subset carries without their is-a
740
+ * relationships - module concepts above all. Both belong in `*`, and both are
741
+ * counted by countAllCodes, so descending from the root made $expand of a
742
+ * `constraint = *` filter disagree with $expand of the whole code system.
743
+ *
744
+ * Inactive concepts are not filtered out here: activeOnly is applied by the
745
+ * expansion itself (see ValueSetExpander.processCodes), which is what lets one
746
+ * enumeration serve both activeOnly=true and activeOnly=false.
682
747
  * @returns {number[]}
683
748
  */
684
- _eclEnumerateActiveConcepts = function (opContext) {
685
- // The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
686
- // 138875005 |SNOMED CT Concept| and its descendants. This excludes the
687
- // foundation-metadata concepts that appear in a subset's concept table
688
- // (referenced by the RF2 files as module / type / acceptability ... ids)
689
- // without their is-a relationships, so they are not reachable from the root
690
- // and are not returned by `*` (matching Snowstorm). On a full edition every
691
- // real concept is under the root, so nothing is lost.
692
- const rootRes = this.concepts.findConcept(138875005n);
693
- if (rootRes && rootRes.found) {
694
- return this.filterIsA(138875005n, true).descendants;
695
- }
696
- // Fallback (no recognisable root): every active concept.
749
+ _eclEnumerateAllConcepts = function (opContext) {
697
750
  const all = [];
698
751
  const n = this.concepts.count();
699
752
  for (let i = 0; i < n; i++) {
700
- if (opContext) opContext.deadCheck('ecl:enumerateActiveConcepts');
701
- const concept = this.concepts.getConceptByCount(i);
702
- if ((concept.flags & 0x0F) === 0) {
703
- all.push(concept.index);
704
- }
753
+ if (opContext) opContext.deadCheck('ecl:enumerateAllConcepts');
754
+ all.push(this.concepts.getConceptByCount(i).index);
705
755
  }
706
756
  return all;
707
757
  };
@@ -918,7 +968,7 @@ class SnomedServices {
918
968
  };
919
969
 
920
970
  /**
921
- * Wildcard — all active concepts. The eclWildcard flag tells filterCheck /
971
+ * Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
922
972
  * filterLocate to accept every active concept without enumeration.
923
973
  * @returns {SnomedFilterContext}
924
974
  */
@@ -1312,15 +1362,15 @@ class SnomedServices {
1312
1362
 
1313
1363
  /**
1314
1364
  * Like _eclToIndexArray, but if the context is a bare wildcard (no
1315
- * descendants populated) it materialises the full active-concept list
1316
- * via _eclEnumerateActiveConcepts. Used by dotted/refined evaluation,
1365
+ * descendants populated) it materialises the full concept list
1366
+ * via _eclEnumerateAllConcepts. Used by dotted/refined evaluation,
1317
1367
  * which need an explicit concept set to iterate over.
1318
1368
  * @param {SnomedFilterContext} ctx
1319
1369
  * @returns {number[]}
1320
1370
  */
1321
1371
  _eclResolveSet = function (ctx, opContext) {
1322
1372
  if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1323
- return this._eclEnumerateActiveConcepts(opContext);
1373
+ return this._eclEnumerateAllConcepts(opContext);
1324
1374
  }
1325
1375
  return this._eclToIndexArray(ctx);
1326
1376
  };
@@ -1369,9 +1419,9 @@ class SnomedServices {
1369
1419
  const rightSet = new Set(this._eclToIndexArray(right));
1370
1420
 
1371
1421
  if (left.eclWildcard) {
1372
- // Wildcard minus the right set: use the shared active-concept enumeration
1373
- // (the root hierarchy) so `*` behaves consistently everywhere.
1374
- result.descendants = this._eclEnumerateActiveConcepts(opContext)
1422
+ // Wildcard minus the right set: use the shared enumeration so `*` behaves
1423
+ // consistently everywhere.
1424
+ result.descendants = this._eclEnumerateAllConcepts(opContext)
1375
1425
  .filter(idx => !rightSet.has(idx));
1376
1426
  return result;
1377
1427
  }
@@ -1734,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
1734
1784
  }
1735
1785
  }
1736
1786
 
1737
- async incompleteValidationMessage(context) {
1738
-
1739
- const ctxt = await this.#ensureContext(context);
1740
-
1741
- if (!ctxt) return null;
1742
-
1743
- if (ctxt.isComplex()) {
1744
- return "The expression is grammatically correct and the concepts are valid, but the expression has not been checked against the SNOMED CT concept model (MRCM)";
1745
- } else {
1746
- return null;
1747
- }
1748
- }
1749
-
1750
1787
  async locateIsA(code, parent, disallowParent = false) {
1751
1788
 
1752
1789
 
@@ -1881,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
1881
1918
  if (ctxt instanceof SnomedExpressionContext) {
1882
1919
  // ignore concepts for now, but list refinements and refinement groups
1883
1920
  for (const refinement of ctxt.expression.refinements) {
1884
- const codeA = refinement.name.code;
1885
- const codeB = refinement.value.describe();
1886
- const description = await this.display(codeB);
1887
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1888
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1921
+ await this._addRefinementProperty(params, refinement);
1889
1922
  }
1890
1923
  for (const refinementGroup of ctxt.expression.refinementGroups) {
1891
1924
  for (const refinement of refinementGroup.refinements) {
1892
- const codeA = refinement.name.code;
1893
- const codeB = refinement.value.describe();
1894
- const description = await this.display(codeB);
1895
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1896
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1925
+ await this._addRefinementProperty(params, refinement);
1897
1926
  }
1898
1927
  }
1899
1928
  }
1900
1929
  }
1901
1930
  }
1902
1931
 
1932
+ /**
1933
+ * Report one refinement of a post-coordinated expression as a lookup property.
1934
+ *
1935
+ * The value is handled as an expression, not as a string: SnomedExpression's
1936
+ * describe() is a human-readable rendering that joins concepts and refinements
1937
+ * with commas ("85562004,272741003=24028007"), so round-tripping it through
1938
+ * display() parsed it back as SCG and failed on any refined value.
1939
+ *
1940
+ * @param {Object} params - the Parameters being built
1941
+ * @param {SnomedRefinement} refinement
1942
+ */
1943
+ async _addRefinementProperty(params, refinement) {
1944
+ const codeA = refinement.name.code;
1945
+ const value = SnomedExpressionContext.fromExpression('', refinement.value);
1946
+ const codeB = await this.code(value);
1947
+ const description = await this.display(value);
1948
+ const p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1949
+ p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1950
+ }
1951
+
1903
1952
  // Filter support
1904
1953
  async doesFilter(prop, op, value) {
1905
1954
  if (prop === 'concept') {
@@ -2131,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
2131
2180
  // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
2132
2181
  // materialised descendant list - so count the active concepts it denotes.
2133
2182
  if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
2134
- return this.sct._eclEnumerateActiveConcepts(this.opContext).length;
2183
+ return this.sct._eclEnumerateAllConcepts(this.opContext).length;
2135
2184
  }
2136
2185
  if (set.matches && set.matches.length > 0) {
2137
2186
  return set.matches.length;
@@ -2183,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
2183
2232
  const reference = ctxt.getReference();
2184
2233
 
2185
2234
  if (set.eclWildcard) {
2186
- return this.sct.isActive(reference) ? ctxt : null;
2235
+ return ctxt; // `*` is every concept; inactive ones included
2187
2236
  }
2188
2237
 
2189
2238
  // A post-coordinated expression validated against an ECL value set: test the
@@ -2266,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
2266
2315
  return this.#descendantsHas(set, reference);
2267
2316
  }
2268
2317
  if (set.eclWildcard) {
2269
- return this.sct.isActive(reference);
2318
+ return true; // `*` is every concept; inactive ones included
2270
2319
  }
2271
2320
  return false;
2272
2321
  }
@@ -173,6 +173,45 @@ class ConceptMap extends CanonicalResource {
173
173
  return result;
174
174
  }
175
175
 
176
+ /**
177
+ * The canonical references this map defers to. Only group.unmapped.otherMap creates
178
+ * such a dependency: a map that names another map cannot be resolved without it, so
179
+ * it has to be consulted first.
180
+ * @returns {string[]} canonical references, as written (may or may not be versioned)
181
+ */
182
+ listDependencies() {
183
+ const result = [];
184
+ for (const g of this.jsonObj.group || []) {
185
+ if (g.unmapped && g.unmapped.mode === 'other-map' && g.unmapped.otherMap) {
186
+ if (!result.includes(g.unmapped.otherMap)) {
187
+ result.push(g.unmapped.otherMap);
188
+ }
189
+ }
190
+ }
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * The groups that would have been consulted for this coding, whether or not any of
196
+ * their elements matched. group.unmapped only applies to a group that was in scope
197
+ * for the translation, so the caller needs the same source/target test that
198
+ * listTranslations applies - without the per-element filter.
199
+ * @returns {Object[]} the matching group objects
200
+ */
201
+ listGroupsInScope(coding, targetScope, targetSystem) {
202
+ const result = [];
203
+ const vurl = VersionUtilities.vurl(coding.system, coding.version);
204
+ const all = this.canonicalMatches(targetScope, this.targetScope);
205
+ for (const g of this.jsonObj.group || []) {
206
+ const sourceOk = this.canonicalMatches(vurl, g.source);
207
+ const targetOk = !targetSystem || this.canonicalMatches(targetSystem, g.target);
208
+ if (all || (sourceOk && targetOk)) {
209
+ result.push(g);
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
176
215
  listTranslationsReverse(coding, targetScope, sourceSystem) {
177
216
  let result = [];
178
217
  let vurl = VersionUtilities.vurl(coding.system, coding.version);