fhirsmith 0.13.4 → 0.14.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
@@ -0,0 +1,396 @@
1
+ //
2
+ // ClosureStore - persistent storage for $closure tables
3
+ //
4
+ // A closure table is named by the client, lives across sessions (and server restarts),
5
+ // and is shared across FHIR versions: the same name reaches the same table from /r4 and
6
+ // /r5. What's held for each table:
7
+ //
8
+ // closure_table the name, the current version, when it was created / last used, and
9
+ // how many concepts and entries it holds (kept up to date as it grows,
10
+ // so the server's home page can report totals without counting rows)
11
+ // closure_param the parameters the table was initialised with, as an ordered series
12
+ // of name = value pairs (value is the JSON of the Parameters.parameter
13
+ // entry, so tx-resource resources and typed values survive). These are
14
+ // replayed on every later call: they are how the table remembers the
15
+ // code systems a client supplied, and the version / supplement rules
16
+ // closure_concept every concept added, and the version it was added in
17
+ // closure_entry one row per related pair (narrower -> broader, or equivalent), and the
18
+ // version it was added in - which is what makes resync work
19
+ //
20
+ // better-sqlite3 on purpose: every write is synchronous, so a write transaction can never
21
+ // be interleaved with another request's. The only thing that needs a lock is the span of
22
+ // one $closure call on one table - read the table, compute (which awaits, and yields),
23
+ // write - and that is the per-table lock below.
24
+ //
25
+ // Synchronous also means every statement blocks the event loop while it runs, so nothing
26
+ // here reads or writes an unbounded amount in one go: reads that can be large are paged
27
+ // (entriesPage), and the size of what one call can write is capped by the worker.
28
+ //
29
+
30
+ const path = require('path');
31
+ const fs = require('fs');
32
+ const Database = require('better-sqlite3');
33
+ const folders = require('../../library/folder-setup');
34
+
35
+ const NARROWER = 'source-is-narrower-than-target';
36
+ const EQUIVALENT = 'equivalent';
37
+
38
+ class ClosureStore {
39
+
40
+ /**
41
+ * @param {Object} config - modules.tx.closure
42
+ * @param {Object} [log]
43
+ */
44
+ constructor(config, log) {
45
+ this.config = config || {};
46
+ this.log = log || console;
47
+ this.db = null;
48
+ this.path = ClosureStore.resolvePath(this.config);
49
+ this.locks = new Map(); // table name -> { tail: Promise, count: holder + waiters }
50
+ }
51
+
52
+ /**
53
+ * config.database if given (relative paths resolve under the data folder's databases
54
+ * directory), otherwise closure.db there. ':memory:' is passed through, for tests.
55
+ */
56
+ static resolvePath(config) {
57
+ const configured = config && config.database;
58
+ if (!configured) {
59
+ return path.join(folders.databasesDir(), 'closure.db');
60
+ }
61
+ if (configured === ':memory:' || path.isAbsolute(configured)) {
62
+ return configured;
63
+ }
64
+ return path.join(folders.databasesDir(), configured);
65
+ }
66
+
67
+ open() {
68
+ if (this.path !== ':memory:') {
69
+ fs.mkdirSync(path.dirname(this.path), {recursive: true});
70
+ }
71
+ this.db = new Database(this.path);
72
+ this.db.pragma('journal_mode = WAL');
73
+ this.db.pragma('foreign_keys = ON');
74
+ this.db.exec(`
75
+ CREATE TABLE IF NOT EXISTS closure_table (
76
+ id INTEGER PRIMARY KEY,
77
+ name TEXT NOT NULL UNIQUE,
78
+ version INTEGER NOT NULL DEFAULT 0,
79
+ created TEXT NOT NULL,
80
+ last_used TEXT NOT NULL,
81
+ concept_count INTEGER NOT NULL DEFAULT 0,
82
+ entry_count INTEGER NOT NULL DEFAULT 0
83
+ );
84
+ CREATE TABLE IF NOT EXISTS closure_param (
85
+ table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
86
+ seq INTEGER NOT NULL,
87
+ name TEXT NOT NULL,
88
+ value TEXT NOT NULL,
89
+ PRIMARY KEY (table_id, seq)
90
+ );
91
+ CREATE TABLE IF NOT EXISTS closure_concept (
92
+ id INTEGER PRIMARY KEY,
93
+ table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
94
+ system TEXT NOT NULL,
95
+ code TEXT NOT NULL,
96
+ display TEXT,
97
+ added_version INTEGER NOT NULL,
98
+ UNIQUE (table_id, system, code)
99
+ );
100
+ CREATE TABLE IF NOT EXISTS closure_entry (
101
+ table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
102
+ source_id INTEGER NOT NULL REFERENCES closure_concept(id) ON DELETE CASCADE,
103
+ target_id INTEGER NOT NULL REFERENCES closure_concept(id) ON DELETE CASCADE,
104
+ relationship TEXT NOT NULL,
105
+ added_version INTEGER NOT NULL,
106
+ PRIMARY KEY (table_id, source_id, target_id)
107
+ );
108
+ CREATE INDEX IF NOT EXISTS closure_entry_version ON closure_entry (table_id, added_version);
109
+ `);
110
+ // databases made before the counts were kept: add them, and fill them in once
111
+ const columns = this.db.prepare('PRAGMA table_info(closure_table)').all().map(c => c.name);
112
+ if (!columns.includes('concept_count')) {
113
+ this.db.exec(`
114
+ ALTER TABLE closure_table ADD COLUMN concept_count INTEGER NOT NULL DEFAULT 0;
115
+ ALTER TABLE closure_table ADD COLUMN entry_count INTEGER NOT NULL DEFAULT 0;
116
+ UPDATE closure_table SET
117
+ concept_count = (SELECT count(*) FROM closure_concept c WHERE c.table_id = closure_table.id),
118
+ entry_count = (SELECT count(*) FROM closure_entry e WHERE e.table_id = closure_table.id);
119
+ `);
120
+ }
121
+ this.stmt = {
122
+ getTable: this.db.prepare('SELECT id, name, version, created, last_used FROM closure_table WHERE name = ?'),
123
+ countTables: this.db.prepare('SELECT count(*) AS n FROM closure_table'),
124
+ insertTable: this.db.prepare('INSERT INTO closure_table (name, version, created, last_used) VALUES (?, 0, ?, ?)'),
125
+ deleteTable: this.db.prepare('DELETE FROM closure_table WHERE id = ?'),
126
+ touchTable: this.db.prepare('UPDATE closure_table SET last_used = ? WHERE id = ?'),
127
+ setVersion: this.db.prepare(`UPDATE closure_table SET version = ?, last_used = ?,
128
+ concept_count = concept_count + ?, entry_count = entry_count + ? WHERE id = ?`),
129
+ totals: this.db.prepare(`SELECT count(*) AS tables, coalesce(sum(concept_count), 0) AS concepts,
130
+ coalesce(sum(entry_count), 0) AS entries FROM closure_table`),
131
+ insertParam: this.db.prepare('INSERT INTO closure_param (table_id, seq, name, value) VALUES (?, ?, ?, ?)'),
132
+ getParams: this.db.prepare('SELECT name, value FROM closure_param WHERE table_id = ? ORDER BY seq'),
133
+ getConcepts: this.db.prepare('SELECT id, system, code, display FROM closure_concept WHERE table_id = ?'),
134
+ countConcepts: this.db.prepare('SELECT count(*) AS n FROM closure_concept WHERE table_id = ?'),
135
+ insertConcept: this.db.prepare('INSERT INTO closure_concept (table_id, system, code, display, added_version) VALUES (?, ?, ?, ?, ?)'),
136
+ insertEntry: this.db.prepare('INSERT OR IGNORE INTO closure_entry (table_id, source_id, target_id, relationship, added_version) VALUES (?, ?, ?, ?, ?)'),
137
+ countEntries: this.db.prepare('SELECT count(*) AS n FROM closure_entry WHERE table_id = ?'),
138
+ entriesPage: this.db.prepare(`
139
+ SELECT e.rowid AS rowid,
140
+ s.system AS source_system, s.code AS source_code, s.display AS source_display,
141
+ t.system AS target_system, t.code AS target_code, t.display AS target_display,
142
+ e.relationship
143
+ FROM closure_entry e
144
+ JOIN closure_concept s ON s.id = e.source_id
145
+ JOIN closure_concept t ON t.id = e.target_id
146
+ WHERE e.table_id = ? AND e.added_version > ? AND e.rowid > ?
147
+ ORDER BY e.rowid
148
+ LIMIT ?`),
149
+ entriesSince: this.db.prepare(`
150
+ SELECT s.system AS source_system, s.code AS source_code, s.display AS source_display,
151
+ t.system AS target_system, t.code AS target_code, t.display AS target_display,
152
+ e.relationship
153
+ FROM closure_entry e
154
+ JOIN closure_concept s ON s.id = e.source_id
155
+ JOIN closure_concept t ON t.id = e.target_id
156
+ WHERE e.table_id = ? AND e.added_version > ?`),
157
+ staleTables: this.db.prepare('SELECT id, name FROM closure_table WHERE last_used < ?')
158
+ };
159
+ }
160
+
161
+ close() {
162
+ if (this.db) {
163
+ this.db.close();
164
+ this.db = null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Run fn with exclusive use of the named table. Calls on different tables run in
170
+ * parallel; calls on the same table queue, in arrival order. fn may await (and yield)
171
+ * as much as it likes - nothing else touches that table until it settles.
172
+ *
173
+ * The queue is bounded, and so is the wait, so a flood of calls on one table can't pile
174
+ * up requests (and their sockets) without limit. A caller that gives up - timeout, or
175
+ * opts.waitFor throwing because the client went away - never runs fn, and passes its
176
+ * turn on to the next in line as soon as it comes.
177
+ *
178
+ * @param {string} name
179
+ * @param {Function} fn - async () => result
180
+ * @param {Object} [opts]
181
+ * @param {number} [opts.maxQueue] - most calls allowed in the queue, counting the one
182
+ * running; beyond that the call fails at once. 0/absent = no limit
183
+ * @param {number} [opts.timeoutMs] - longest to wait for the lock. 0/absent = no limit
184
+ * @param {Function} [opts.waitFor] - (promise) => promise; wraps the wait (the worker
185
+ * uses OperationContext.waitFor, so the wait isn't charged to the compute deadline and
186
+ * a disconnected client stops waiting)
187
+ * @throws {Error} with .closureBusy = 'queue' | 'timeout' when the lock isn't got
188
+ */
189
+ async withLock(name, fn, opts = {}) {
190
+ let q = this.locks.get(name);
191
+ if (!q) {
192
+ q = { tail: Promise.resolve(), count: 0 };
193
+ this.locks.set(name, q);
194
+ }
195
+ if (opts.maxQueue && q.count >= opts.maxQueue) {
196
+ const error = new Error(`closure table '${name}' is busy`);
197
+ error.closureBusy = 'queue';
198
+ throw error;
199
+ }
200
+ const prior = q.tail;
201
+ let release;
202
+ const mine = new Promise(resolve => { release = resolve; });
203
+ q.tail = prior.then(() => mine);
204
+ q.count++;
205
+
206
+ let timer = null;
207
+ let acquired = false;
208
+ try {
209
+ let wait = prior;
210
+ if (opts.timeoutMs) {
211
+ wait = Promise.race([prior, new Promise((resolve, reject) => {
212
+ timer = setTimeout(() => {
213
+ const error = new Error(`timed out waiting for closure table '${name}'`);
214
+ error.closureBusy = 'timeout';
215
+ reject(error);
216
+ }, opts.timeoutMs);
217
+ })]);
218
+ }
219
+ await (opts.waitFor ? opts.waitFor(wait) : wait);
220
+ acquired = true;
221
+ return await fn();
222
+ } finally {
223
+ if (timer) {
224
+ clearTimeout(timer);
225
+ }
226
+ if (acquired) {
227
+ release();
228
+ } else {
229
+ prior.then(release); // hand the turn on when it comes
230
+ }
231
+ q.count--;
232
+ if (q.count === 0 && this.locks.get(name) === q) {
233
+ this.locks.delete(name);
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * @param {string} name
240
+ * @returns {{id, name, version, created, last_used}|null}
241
+ */
242
+ getTable(name) {
243
+ return this.stmt.getTable.get(name) || null;
244
+ }
245
+
246
+ tableCount() {
247
+ return this.stmt.countTables.get().n;
248
+ }
249
+
250
+ conceptCount(tableId) {
251
+ return this.stmt.countConcepts.get(tableId).n;
252
+ }
253
+
254
+ entryCount(tableId) {
255
+ return this.stmt.countEntries.get(tableId).n;
256
+ }
257
+
258
+ /**
259
+ * One page of the entries added after the given version, in the order they were
260
+ * written. Page through with the last row's rowid as afterRowid (start with 0).
261
+ * @returns {Array<{rowid, source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
262
+ */
263
+ entriesPage(tableId, version, afterRowid, limit) {
264
+ return this.stmt.entriesPage.all(tableId, version, afterRowid, limit);
265
+ }
266
+
267
+ /**
268
+ * Create a table, replacing any table of the same name (the caller has already decided
269
+ * whether that's allowed). Its parameters are fixed here, for the life of the table.
270
+ * @param {string} name
271
+ * @param {Array<Object>} params - Parameters.parameter entries that configure the table
272
+ * @returns {Object} the new table row
273
+ */
274
+ createTable(name, params) {
275
+ const now = new Date().toISOString();
276
+ const tx = this.db.transaction(() => {
277
+ const existing = this.stmt.getTable.get(name);
278
+ if (existing) {
279
+ this.stmt.deleteTable.run(existing.id);
280
+ }
281
+ const info = this.stmt.insertTable.run(name, now, now);
282
+ const id = info.lastInsertRowid;
283
+ params.forEach((p, i) => this.stmt.insertParam.run(id, i, p.name, JSON.stringify(p)));
284
+ return this.stmt.getTable.get(name);
285
+ });
286
+ return tx();
287
+ }
288
+
289
+ /**
290
+ * The parameters the table was initialised with, as Parameters.parameter entries.
291
+ */
292
+ getParams(tableId) {
293
+ return this.stmt.getParams.all(tableId).map(r => JSON.parse(r.value));
294
+ }
295
+
296
+ /**
297
+ * @returns {Array<{id, system, code, display}>}
298
+ */
299
+ getConcepts(tableId) {
300
+ return this.stmt.getConcepts.all(tableId);
301
+ }
302
+
303
+ touch(tableId) {
304
+ this.stmt.touchTable.run(new Date().toISOString(), tableId);
305
+ }
306
+
307
+ /**
308
+ * Record the outcome of an add, all at once: the new concepts, the new entries, and the
309
+ * table's new version. Nothing is written unless everything is.
310
+ *
311
+ * @param {Object} table - the table row (id, version)
312
+ * @param {Array<{key, system, code, display}>} concepts - new concepts; key identifies
313
+ * the concept in the entries below
314
+ * @param {Array<{source, target, relationship}>} entries - source/target are either the
315
+ * id of an existing concept (number) or the key of a new one (string)
316
+ * @returns {number} the table's version after the add
317
+ */
318
+ recordAdd(table, concepts, entries) {
319
+ if (concepts.length === 0) {
320
+ this.touch(table.id);
321
+ return table.version;
322
+ }
323
+ const version = table.version + 1;
324
+ const now = new Date().toISOString();
325
+ const tx = this.db.transaction(() => {
326
+ const ids = new Map();
327
+ for (const c of concepts) {
328
+ const info = this.stmt.insertConcept.run(table.id, c.system, c.code, c.display || null, version);
329
+ ids.set(c.key, info.lastInsertRowid);
330
+ }
331
+ const id = ref => (typeof ref === 'string' ? ids.get(ref) : ref);
332
+ let added = 0;
333
+ for (const e of entries) {
334
+ added += this.stmt.insertEntry.run(table.id, id(e.source), id(e.target), e.relationship, version).changes;
335
+ }
336
+ this.stmt.setVersion.run(version, now, concepts.length, added, table.id);
337
+ });
338
+ tx();
339
+ return version;
340
+ }
341
+
342
+ /**
343
+ * Every entry added after the given version.
344
+ * @returns {Array<{source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
345
+ */
346
+ entriesSince(tableId, version) {
347
+ return this.stmt.entriesSince.all(tableId, version);
348
+ }
349
+
350
+ /**
351
+ * Totals across every table, and the size of the database on disk (the main file plus
352
+ * the write-ahead log; null for an in-memory database). Cheap: the counts are kept on
353
+ * each table row, so this reads at most maxTables rows.
354
+ * @returns {{tables: number, concepts: number, entries: number, bytes: number|null}}
355
+ */
356
+ totals() {
357
+ const t = this.stmt.totals.get();
358
+ let bytes = null;
359
+ if (this.path !== ':memory:') {
360
+ bytes = 0;
361
+ for (const f of [this.path, this.path + '-wal']) {
362
+ try {
363
+ bytes += fs.statSync(f).size;
364
+ } catch (e) {
365
+ // no WAL file yet
366
+ }
367
+ }
368
+ }
369
+ return { tables: t.tables, concepts: t.concepts, entries: t.entries, bytes };
370
+ }
371
+
372
+ /**
373
+ * Drop tables that nobody has used for the given number of days. Each table is dropped
374
+ * under its lock (a table in use is not pulled out from under the call using it - it is
375
+ * checked again once the lock is held), one at a time, yielding in between.
376
+ * @returns {Promise<Array<string>>} the names dropped
377
+ */
378
+ async pruneUnused(days) {
379
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
380
+ const stale = this.stmt.staleTables.all(cutoff);
381
+ const dropped = [];
382
+ for (const t of stale) {
383
+ await this.withLock(t.name, async () => {
384
+ const now = this.db ? this.stmt.getTable.get(t.name) : null;
385
+ if (now && now.id === t.id && now.last_used < cutoff) {
386
+ this.stmt.deleteTable.run(t.id);
387
+ dropped.push(t.name);
388
+ }
389
+ });
390
+ await new Promise(resolve => setImmediate(resolve));
391
+ }
392
+ return dropped;
393
+ }
394
+ }
395
+
396
+ module.exports = { ClosureStore, NARROWER, EQUIVALENT };
package/tx/cs/cs-api.js CHANGED
@@ -67,7 +67,12 @@ class CodeSystemProvider {
67
67
  */
68
68
 
69
69
  /**
70
- * @returns {string} uri for the code system
70
+ * The display name of the code system - what $lookup reports as its name parameter, and
71
+ * what a person reads. Every provider should override this; the fallback is the
72
+ * versioned uri, which is not a name, and a provider that forgets ends up reporting
73
+ * "http://snomed.info/sct|http://snomed.info/sct/731000124108/version/20230301" where
74
+ * the others report "LOINC".
75
+ * @returns {string} display name for the code system
71
76
  */
72
77
  name() { return this.system() + (this.version() ? "|"+this.version() : "") }
73
78
 
@@ -428,6 +428,20 @@ class RxNormServices extends CodeSystemProvider {
428
428
  filterContext.filters.push(filter);
429
429
  }
430
430
 
431
+ /**
432
+ * Whether RXNSTEMS can hold this stem at all.
433
+ *
434
+ * The importer only stems words that are longer than two characters and start with a
435
+ * letter (see extractWords), so the table has nothing that begins with a digit. Every
436
+ * token of a search is ANDed, so a token the table cannot hold takes the whole search
437
+ * to nothing: "acetaminophen" found thousands of codes while "acetaminophen 500" found
438
+ * none, which is not a result anyone would expect from adding a dose.
439
+ * @private
440
+ */
441
+ #stemIsIndexed(stem) {
442
+ return stem.length > 2 && /^[a-z]/.test(stem);
443
+ }
444
+
431
445
  async searchFilter(filterContext, filter, sort) {
432
446
 
433
447
  if (!filter || !filter.stems || filter.stems.length === 0) {
@@ -437,9 +451,18 @@ class RxNormServices extends CodeSystemProvider {
437
451
  for (let i = 0; i < filter.stems.length; i++) {
438
452
  const stem = filter.stems[i];
439
453
  const rxnormFilter = new RxNormFilterHolder();
440
- rxnormFilter.text = true;
441
- rxnormFilter.sql = ` AND (${this.getCodeField()} = s${i}.CUI AND s${i}.stem LIKE $stem${i})`;
442
- rxnormFilter.params[`stem${i}`] = this.#sqlWrapString(stem) + '%';
454
+ if (this.#stemIsIndexed(stem)) {
455
+ rxnormFilter.text = true;
456
+ rxnormFilter.sql = ` AND (${this.getCodeField()} = s${i}.CUI AND s${i}.stem LIKE $stem${i})`;
457
+ rxnormFilter.params[`stem${i}`] = stem + '%';
458
+ } else {
459
+ // Not in the stem table, so match the token against the text itself. Left as a
460
+ // plain (non-text) filter so that executeFilters does not join RXNSTEMS for it.
461
+ // Unindexed, but it only ever narrows rows the other tokens have already found,
462
+ // and the tokens are alphanumeric, so there are no LIKE wildcards to escape.
463
+ rxnormFilter.sql = ` AND STR LIKE $text${i}`;
464
+ rxnormFilter.params[`text${i}`] = '%' + ((filter.words && filter.words[i]) || stem) + '%';
465
+ }
443
466
 
444
467
  filterContext.filters.push(rxnormFilter);
445
468
  }
@@ -470,17 +493,21 @@ class RxNormServices extends CodeSystemProvider {
470
493
  }
471
494
  }
472
495
 
473
- // Add text search joins and filters
496
+ // Add text search joins and filters. Each one is renumbered to its position among the
497
+ // text filters, which is not its position among the filters - searchFilter leaves gaps
498
+ // where a token went to a plain STR match instead. The alias, the placeholder and the
499
+ // parameter key all carry that number and have to move together: renaming the key but
500
+ // not the placeholder leaves the query asking for a parameter that is not there.
474
501
  for (const filter of filterContext.filters) {
475
502
  if (filter.text) {
476
503
  sql2 += `, rxnstems as s${stemIndex}`;
477
- const stemSql = filter.sql.replace(/s\d+/g, `s${stemIndex}`);
504
+ const stemSql = filter.sql
505
+ .replace(/\bs\d+\./g, `s${stemIndex}.`)
506
+ .replace(/\$stem\d+/g, `$stem${stemIndex}`);
478
507
  sql1 += ' ' + stemSql;
479
508
 
480
- // Update parameter keys to match stem index
481
509
  for (const [key, value] of Object.entries(filter.params)) {
482
- const newKey = key.replace(/\d+/, stemIndex.toString());
483
- allParams[newKey] = value;
510
+ allParams[key.replace(/\d+$/, stemIndex.toString())] = value;
484
511
  }
485
512
  stemIndex++;
486
513
  }
@@ -1676,6 +1676,19 @@ class SnomedProvider extends BaseCSServices {
1676
1676
  return actualVersion && actualVersion.startsWith(checkVersion);
1677
1677
  }
1678
1678
 
1679
+ /**
1680
+ * The display name of the code system, as $lookup reports it.
1681
+ *
1682
+ * Without this, CodeSystemProvider's fallback applies, which returns the versioned uri -
1683
+ * so a SNOMED lookup answered "http://snomed.info/sct|http://snomed.info/sct/731000124108
1684
+ * /version/20230301" where every other code system answers something like "LOINC". The
1685
+ * edition is named because for SNOMED that is the part worth knowing; the version
1686
+ * parameter carries the uri.
1687
+ */
1688
+ name() {
1689
+ return this.sct.getDescription();
1690
+ }
1691
+
1679
1692
  description() {
1680
1693
  return this.sct.getDescription();
1681
1694
  }
@@ -2787,7 +2800,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
2787
2800
 
2788
2801
  if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
2789
2802
  url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
2790
- url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
2803
+ url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
2791
2804
  id = url.substring(qIdx);
2792
2805
  } else {
2793
2806
  return null;
@@ -3005,8 +3018,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3005
3018
  description: `The concept map implicitly defined by the ${name} Association Reference Set`,
3006
3019
  copyright: 'This value set includes content from SNOMED CT, which is copyright © 2002+ International Health Terminology Standards Development Organisation (SNOMED International), and distributed by agreement between SNOMED International and HL7',
3007
3020
  status: 'active',
3008
- sourceUri: `${this.system}?fhir_vs`,
3009
- targetUri: `${this.system}?fhir_vs`,
3021
+ sourceUri: `${this.system()}?fhir_vs`,
3022
+ targetUri: `${this.system()}?fhir_vs`,
3010
3023
  group: [{
3011
3024
  source: 'http://snomed.info/sct',
3012
3025
  target: 'http://snomed.info/sct'
@@ -3027,6 +3040,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3027
3040
  function getEditionName(edition) {
3028
3041
  const editionMap = {
3029
3042
  '900000000000207008': 'International Edition',
3043
+ // The terminology-ecosystem test distribution, published under xsct - see the
3044
+ // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3045
+ '31000003106': 'Test Edition',
3030
3046
  '449081005': 'International Spanish Edition',
3031
3047
  '11000221109': 'Argentinian Edition',
3032
3048
  '32506021000036107': 'Australian Edition (with drug extension)',
@@ -3063,6 +3079,9 @@ function getEditionName(edition) {
3063
3079
  function getEditionCode(edition) {
3064
3080
  const editionMap = {
3065
3081
  '900000000000207008': 'Intl',
3082
+ // The terminology-ecosystem test distribution, published under xsct - see the
3083
+ // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3084
+ '31000003106': 'Test',
3066
3085
  '449081005': 'es',
3067
3086
  '11000221109': 'AR-es',
3068
3087
  '32506021000036107': 'AU+',