fhirsmith 0.15.0 → 0.16.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.
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
4
+ //
5
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
6
+ //
7
+
8
+ /**
9
+ * One-off repair of a package server's database and mirror, for a database built before
10
+ * the crawler stored each id#version only once.
11
+ *
12
+ * node packages/utilities/dedupe-versions.js -db <packages.db> -mirror <mirror folder> [-apply] [-verbose]
13
+ *
14
+ * Without -apply it reports what it would do and changes nothing. Back up the database
15
+ * first. It is safe to run while the server is up, but best between crawler runs. It can be
16
+ * run with sudo: rewritten mirror files keep the owner, group and mode of the files they replace.
17
+ *
18
+ * 1. Duplicate rows. For each id#version stored more than once, it keeps one row, using the
19
+ * crawler's rule: a direct copy beats a Simplifier copy (a secondary echo); otherwise the
20
+ * first stored wins. Two direct copies with different content are a conflict - only their
21
+ * Simplifier rows are removed, and they are listed for manual cleanup. Deleted rows'
22
+ * download counts are added to the kept row.
23
+ * 2. The unique index on (Id, Version), once there are no duplicates left.
24
+ * 3. The mirror. Every row's mirror file must be the bytes that row's Hash describes; a file
25
+ * that is missing or different is rewritten from the row's Content. Conflicts are skipped.
26
+ * The report separates the two: a different file is served with the wrong checksum; a
27
+ * missing one isn't served at all. -verbose lists every missing file.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const crypto = require('crypto');
33
+ // node:sqlite (built into Node 24) rather than an npm package, so this file runs on its own:
34
+ // copy it to the server and run it with node, no install needed
35
+ const { DatabaseSync } = require('node:sqlite');
36
+
37
+ const UNIQUE_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
38
+
39
+ // These two must match PackageCrawler.isSimplifierGuid and PackageCrawler.fixPrefix
40
+ // (tests/packages/package-crawler-versions.test.js checks that they do)
41
+ function isSimplifier(guid) {
42
+ return /^https?:\/\/packages\.simplifier\.net\//i.test(guid || '');
43
+ }
44
+
45
+ function fixPrefix(id) {
46
+ return id && id.startsWith('@') && id.includes('/') ? id.replace('@', '$$').replace('/', '$') : id;
47
+ }
48
+
49
+ const mirrorName = (id, version) => `${fixPrefix(id)}-${version}.tgz`;
50
+
51
+ // When run with sudo, a rewritten file would otherwise belong to root. Give it the owner,
52
+ // group and mode of the file it replaces (or of the mirror folder, for a missing file).
53
+ function keepOwnership(file, like) {
54
+ const st = fs.statSync(like);
55
+ if (process.getuid && process.getuid() === 0) {
56
+ fs.chownSync(file, st.uid, st.gid);
57
+ }
58
+ fs.chmodSync(file, st.isDirectory() ? st.mode & 0o666 : st.mode & 0o7777);
59
+ }
60
+ const sha1 = buf => crypto.createHash('sha1').update(buf).digest('hex');
61
+
62
+ // Which rows of one id#version to keep and delete. rows are in PackageVersionKey order.
63
+ function plan(rows) {
64
+ const direct = rows.filter(r => !isSimplifier(r.GUID));
65
+ if (direct.length === 0) {
66
+ return { keep: [rows[0]], conflict: false };
67
+ }
68
+ if (new Set(direct.map(r => r.Hash)).size === 1) {
69
+ return { keep: [direct[0]], conflict: false };
70
+ }
71
+ return { keep: direct, conflict: true };
72
+ }
73
+
74
+ function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = console.log }) {
75
+ if (!fs.existsSync(dbPath)) {
76
+ throw new Error(`No database at ${dbPath}`); // DatabaseSync would create an empty one
77
+ }
78
+ const db = new DatabaseSync(dbPath);
79
+ db.exec('PRAGMA busy_timeout = 10000');
80
+ const report = { groups: 0, deleted: [], conflicts: [], indexCreated: false, mirror: { checked: 0, ok: 0, rewritten: [], badContent: [] } };
81
+
82
+ try {
83
+ // 1. duplicate rows
84
+ const groups = db.prepare('SELECT Id, Version FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1').all();
85
+ const rowsOf = db.prepare(`SELECT PackageVersionKey, GUID, Hash, DownloadCount FROM PackageVersions
86
+ WHERE Id = ? AND Version = ? ORDER BY PackageVersionKey`);
87
+ const changes = [];
88
+ for (const g of groups) {
89
+ const rows = rowsOf.all(g.Id, g.Version);
90
+ const { keep, conflict } = plan(rows);
91
+ const del = rows.filter(r => !keep.includes(r));
92
+ if (conflict) {
93
+ report.conflicts.push({ id: g.Id, version: g.Version, kept: keep.map(r => r.GUID) });
94
+ }
95
+ if (del.length > 0) {
96
+ changes.push({ id: g.Id, version: g.Version, keep: keep[0], del });
97
+ for (const r of del) {
98
+ report.deleted.push({ id: g.Id, version: g.Version, guid: r.GUID, keptGuid: keep[0].GUID });
99
+ }
100
+ }
101
+ }
102
+ report.groups = groups.length;
103
+ const toDelete = new Set(changes.flatMap(c => c.del.map(r => r.PackageVersionKey)));
104
+ const conflicted = new Set(report.conflicts.map(c => `${c.id}#${c.version}`));
105
+
106
+ if (apply && changes.length > 0) {
107
+ const delRelated = ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']
108
+ .map(t => db.prepare(`DELETE FROM ${t} WHERE PackageVersionKey = ?`));
109
+ const delRow = db.prepare('DELETE FROM PackageVersions WHERE PackageVersionKey = ?');
110
+ const addCount = db.prepare('UPDATE PackageVersions SET DownloadCount = DownloadCount + ? WHERE PackageVersionKey = ?');
111
+ const latest = db.prepare(`SELECT PackageVersionKey, Canonical FROM PackageVersions
112
+ WHERE Id = ? AND Version != 'current' ORDER BY PubDate DESC, Version DESC LIMIT 1`);
113
+ const setCurrent = db.prepare('UPDATE Packages SET CurrentVersion = ?, Canonical = ? WHERE Id = ?');
114
+ db.exec('BEGIN IMMEDIATE');
115
+ try {
116
+ const ids = new Set();
117
+ for (const c of changes) {
118
+ for (const r of c.del) {
119
+ delRelated.forEach(s => s.run(r.PackageVersionKey));
120
+ delRow.run(r.PackageVersionKey);
121
+ addCount.run(r.DownloadCount || 0, c.keep.PackageVersionKey);
122
+ }
123
+ ids.add(c.id);
124
+ }
125
+ for (const id of ids) {
126
+ const l = latest.get(id);
127
+ if (l) {
128
+ setCurrent.run(l.PackageVersionKey, l.Canonical, id);
129
+ }
130
+ }
131
+ db.exec('COMMIT');
132
+ } catch (e) {
133
+ db.exec('ROLLBACK');
134
+ throw e;
135
+ }
136
+ }
137
+ log(`${report.groups} id#versions stored more than once; ${report.deleted.length} rows ${apply ? 'deleted' : 'to delete'}; ${report.conflicts.length} conflicts left for manual cleanup`);
138
+ for (const c of report.conflicts) {
139
+ log(` conflict: ${c.id}#${c.version} has different direct copies: ${c.kept.join(', ')}`);
140
+ }
141
+
142
+ // 2. unique index
143
+ const remaining = db.prepare('SELECT COUNT(*) AS n FROM (SELECT 1 FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1)').get().n;
144
+ if (apply && remaining === 0) {
145
+ db.exec(UNIQUE_INDEX);
146
+ report.indexCreated = true;
147
+ log('unique index on (Id, Version) created');
148
+ } else if (remaining > 0) {
149
+ log(`unique index on (Id, Version) not created: ${apply ? remaining : report.groups} id#versions still duplicated`);
150
+ }
151
+
152
+ // 3. mirror
153
+ if (mirrorPath) {
154
+ const content = db.prepare('SELECT Content FROM PackageVersions WHERE PackageVersionKey = ?');
155
+ for (const row of db.prepare('SELECT PackageVersionKey, Id, Version, Hash FROM PackageVersions ORDER BY Id, Version').iterate()) {
156
+ if (toDelete.has(row.PackageVersionKey)) {
157
+ continue; // a dry run, and -apply would delete this row
158
+ }
159
+ if (conflicted.has(`${row.Id}#${row.Version}`)) {
160
+ continue; // two rows share the file; left for manual cleanup
161
+ }
162
+ report.mirror.checked++;
163
+ const file = path.join(mirrorPath, mirrorName(row.Id, row.Version));
164
+ const actual = fs.existsSync(file) ? sha1(fs.readFileSync(file)) : null;
165
+ if (actual === row.Hash) {
166
+ report.mirror.ok++;
167
+ continue;
168
+ }
169
+ const buf = Buffer.from(content.get(row.PackageVersionKey).Content);
170
+ if (sha1(buf) !== row.Hash) {
171
+ report.mirror.badContent.push(`${row.Id}#${row.Version}`);
172
+ continue;
173
+ }
174
+ report.mirror.rewritten.push({ id: row.Id, version: row.Version, was: actual || 'missing', hash: row.Hash });
175
+ if (apply) {
176
+ const tmp = `${file}.tmp-${process.pid}`;
177
+ fs.writeFileSync(tmp, buf);
178
+ keepOwnership(tmp, fs.existsSync(file) ? file : mirrorPath);
179
+ fs.renameSync(tmp, file);
180
+ }
181
+ }
182
+ const missing = report.mirror.rewritten.filter(r => r.was === 'missing');
183
+ const different = report.mirror.rewritten.filter(r => r.was !== 'missing');
184
+ const will = apply ? 'was written' : 'will be written';
185
+ log(`mirror ${mirrorPath}: ${report.mirror.checked} database rows checked against their mirror file`);
186
+ log(` ${report.mirror.ok} files match the database`);
187
+ log(` ${missing.length} files are missing from the mirror: the package is in the database, so the file ${will} from it`);
188
+ if (missing.length > report.mirror.checked / 2) {
189
+ log(' (most of the mirror is missing: check that -mirror is the folder the server\'s mirrorPath setting names)');
190
+ }
191
+ for (const r of verbose ? missing : missing.slice(0, 5)) {
192
+ log(` ${r.id}#${r.version}`);
193
+ }
194
+ if (!verbose && missing.length > 5) {
195
+ log(` ... and ${missing.length - 5} more (-verbose lists them all)`);
196
+ }
197
+ log(` ${different.length} files differ from the database - these are the ones served with the wrong checksum; each ${will} from the database`);
198
+ for (const r of different) {
199
+ log(` ${r.id}#${r.version}: file sha1 ${r.was}, database ${r.hash}`);
200
+ }
201
+ if (report.mirror.badContent.length > 0) {
202
+ log(` ${report.mirror.badContent.length} database rows whose content doesn't match their own hash - not touched:`);
203
+ for (const b of report.mirror.badContent) {
204
+ log(` ${b}`);
205
+ }
206
+ }
207
+ }
208
+ } finally {
209
+ db.close();
210
+ }
211
+ return report;
212
+ }
213
+
214
+ function main(argv) {
215
+ const arg = name => {
216
+ const i = argv.indexOf(name);
217
+ return i >= 0 ? argv[i + 1] : undefined;
218
+ };
219
+ const dbPath = arg('-db');
220
+ const mirrorPath = arg('-mirror');
221
+ if (!dbPath) {
222
+ console.error('usage: node packages/utilities/dedupe-versions.js -db <packages.db> [-mirror <mirror folder>] [-apply] [-verbose]');
223
+ return 1;
224
+ }
225
+ const apply = argv.includes('-apply');
226
+ if (!apply) {
227
+ console.log('Dry run: nothing will be changed (use -apply)');
228
+ }
229
+ try {
230
+ dedupe({ dbPath, mirrorPath, apply, verbose: argv.includes('-verbose') });
231
+ } catch (e) {
232
+ console.error(e.message);
233
+ return 1;
234
+ }
235
+ return 0;
236
+ }
237
+
238
+ if (require.main === module) {
239
+ process.exitCode = main(process.argv.slice(2));
240
+ }
241
+
242
+ module.exports = { dedupe, plan, isSimplifier, fixPrefix };
@@ -582,9 +582,16 @@ class RegistryModule {
582
582
  // what the server says about its release (CapabilityStatement.software.releaseDate) -
583
583
  // the only source for software other than FHIRsmith
584
584
  _reportedReleaseDay(row) {
585
- // the day as the server gives it - converting to UTC can move it a day
586
- const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.softwareReleaseDate || '');
587
- return m ? m[1] : '';
585
+ const value = row.softwareReleaseDate || '';
586
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(value);
587
+ if (!m) {
588
+ return '';
589
+ }
590
+ // a date is the day as given. A dateTime is an instant, and different servers running
591
+ // the same release report it in their own time zones (2026-09-05T00:37:46+10:00 and
592
+ // 2026-09-04T16:37:46+02:00), so the day is the UTC day, to be the same for all of them
593
+ const t = value.length > 10 ? new Date(value).getTime() : NaN;
594
+ return isNaN(t) ? m[1] : new Date(t).toISOString().substring(0, 10);
588
595
  }
589
596
 
590
597
  _renderReportedReleaseCells(row, now) {
package/token/token.js CHANGED
@@ -11,7 +11,6 @@ const crypto = require('crypto');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const session = require('express-session');
14
- const SQLiteStore = require('connect-sqlite3')(session);
15
14
  const passport = require('passport');
16
15
  const GoogleStrategy = require('passport-google-oauth20').Strategy;
17
16
  const FacebookStrategy = require('passport-facebook').Strategy;
@@ -19,6 +18,7 @@ const GitHubStrategy = require('passport-github2').Strategy;
19
18
  const rateLimit = require('express-rate-limit');
20
19
  const lusca = require('lusca');
21
20
  const folders = require('../library/folder-setup');
21
+ const { SqliteSessionStore } = require('../library/sqlite-session-store');
22
22
  const escape = require('escape-html');
23
23
 
24
24
 
@@ -155,17 +155,11 @@ class TokenModule {
155
155
  }
156
156
 
157
157
  initializeSession() {
158
- const configDb = this.config.database || 'token.db';
159
- const sessionDbPath = path.isAbsolute(configDb)
160
- ? configDb
161
- : folders.ensureFilePath('token', configDb);
158
+ // sessions live in the module's own database (initializeDatabase has opened it)
159
+ this.sessionStore = new SqliteSessionStore({ db: this.db, table: 'sessions' });
162
160
 
163
161
  const sessionConfig = {
164
- store: new SQLiteStore({
165
- dir: path.dirname(sessionDbPath),
166
- db: path.basename(sessionDbPath),
167
- table: 'sessions'
168
- }),
162
+ store: this.sessionStore,
169
163
  secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
170
164
  name: 'fhir.token.sid', // Don't use default session name
171
165
  resave: false,
@@ -1287,6 +1281,9 @@ class TokenModule {
1287
1281
  }
1288
1282
 
1289
1283
  async shutdown() {
1284
+ if (this.sessionStore) {
1285
+ this.sessionStore.close();
1286
+ }
1290
1287
  if (this.db) {
1291
1288
  await new Promise((resolve) => {
1292
1289
  this.db.close((err) => {
@@ -377,6 +377,8 @@ HTA_LOINC_DESC = LOINC
377
377
  HTA_LOINC_MESSAGE = This material contains content from <a href="http://loinc.org">LOINC</a>. LOINC is copyright &copy; 1995-2020, Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the <a href="http://loinc.org/license">license</a>. LOINC&reg; is a registered United States trademark of Regenstrief Institute, Inc.
378
378
  HTA_SCT_DESC = SNOMED Clinical Terms&reg; (SNOMED CT&reg;)
379
379
  HTA_SCT_MESSAGE = This material contains content that is copyright of SNOMED International. Implementers of these specifications must have the appropriate SNOMED CT Affiliate license - for more information contact <a href="https://www.snomed.org/get-snomed">https://www.snomed.org/get-snomed</a> or <a href="mailto:info@snomed.org">info@snomed.org</a>.
380
+ ICD11_PC_NO_AXES = ''{0}'' cannot be postcoordinated onto ''{1}'': that concept declares no postcoordination axes
381
+ ICD11_PC_VALUE_NOT_ON_AXIS = ''{0}'' is not a permitted value on any postcoordination axis of ''{1}''. That stem declares: {2}
380
382
  IG_DEPENDENCY_CAN_VERSION_ALONE = The canonical URL specifies the version ''{0}'', while the dependency does not. If a version is specified, it must be in the explicit version property
381
383
  IG_DEPENDENCY_CAN_VERSION_ERROR = The canonical URL specifies the version ''{0}'', while the dependency has version ''{1}''. If a version is specified, it must be the same as in the explicit version property
382
384
  IG_DEPENDENCY_CAN_VERSION_NONE = The canonical URL cannot end in ''|''
@@ -1441,6 +1443,7 @@ TEXT_LINK_SELECTOR_INVALID = The textLink selector ''{0}'' is invalid: {1}
1441
1443
  SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND = The element {0} is not valid
1442
1444
  SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND_VER = The element {0} is not valid in version {1}
1443
1445
  FILTER_NOT_UNDERSTOOD = The filter "{0} {1} {2}" is not understood or supported
1446
+ FILTER_VALUE_UNKNOWN_CODE = The filter value ''{0}'' is not a code in {1}
1444
1447
  XHTML_CONTROL_NO_SOURCE = The xhtml node at ''{0}'' does not have a class attribute to indicate its source (boilerplate, generated, or original) and this is required by the profile {1}
1445
1448
  XHTML_XHTML_MIXED_LANG = The xhtml has some language sections ({0}), and also has content that is not in a language section
1446
1449
  XHTML_CONTROL_NO_LANGS = The xhtml has some language sections ({0}), but language sections are prohibited in this context by the profile {1}
package/tx/cs/cs-api.js CHANGED
@@ -74,7 +74,12 @@ class CodeSystemProvider {
74
74
  * the others report "LOINC".
75
75
  * @returns {string} display name for the code system
76
76
  */
77
- name() { return this.system() + (this.version() ? "|"+this.version() : "") }
77
+ /**
78
+ * @param {Languages} [languages] - the request's working languages, for a provider whose name
79
+ * is translated; the default ignores them
80
+ */
81
+ // eslint-disable-next-line no-unused-vars
82
+ name(languages) { return this.system() + (this.version() ? "|"+this.version() : "") }
78
83
 
79
84
  /**
80
85
  * @returns {string} uri for the code system
package/tx/cs/cs-icd11.js CHANGED
@@ -113,8 +113,11 @@ class ICD11Services extends BaseCSServices {
113
113
  /**
114
114
  * The default is system|version, but $lookup's `name` is meant to be a display name for
115
115
  * the code system. OMOP overrides it the same way; WHO returns the linearization title.
116
+ * It follows the request's languages like any other display, falling back to the primary
117
+ * title. $lookup passes its working languages (displayLanguage, else Accept-Language),
118
+ * since displayLanguage never reaches opContext.langs; other callers pass nothing.
116
119
  */
117
- name() { return this.shared.title; }
120
+ name(languages) { return this.shared.titles?.[this.#displayLang(languages)] || this.shared.title; }
118
121
  description() { return this.shared.title; }
119
122
  totalCount() { return this.shared.count; }
120
123
  defLang() { return 'en'; }
@@ -191,12 +194,13 @@ class ICD11Services extends BaseCSServices {
191
194
  return row ? new ICD11Concept(row) : null;
192
195
  }
193
196
 
194
- /** The three things the expression parser needs from the database. */
197
+ /** What the expression parser needs: the database, and the request's translations. */
195
198
  #expressionContext() {
196
199
  return {
197
200
  get: (sql, params) => this.#get(sql, params),
198
201
  all: (sql, params) => this.#all(sql, params),
199
202
  resolve: (text) => this.#resolve(text),
203
+ translate: (msgId, params) => this.opContext.i18n.translate(msgId, this.opContext.langs, params),
200
204
  };
201
205
  }
202
206
 
@@ -309,8 +313,8 @@ class ICD11Services extends BaseCSServices {
309
313
  * iterable of Language, in quality order; the database holds translations keyed on the
310
314
  * primary subtag, so that is what is matched.
311
315
  */
312
- #displayLang() {
313
- const langs = this.opContext?.langs;
316
+ #displayLang(languages) {
317
+ const langs = languages || this.opContext?.langs;
314
318
  if (langs) {
315
319
  for (const l of langs) {
316
320
  const base = l.language || String(l.code || '').split('-')[0];
@@ -615,8 +619,9 @@ class ICD11Services extends BaseCSServices {
615
619
  if (!target) {
616
620
  // a value set that names a code that is not there is broken, and saying so is more
617
621
  // use than quietly expanding to nothing
618
- throw new Issue('error', 'code-invalid', null, null,
619
- `The filter value '${value}' is not a code in ${this.system()}`, 'invalid-code', 422);
622
+ throw new Issue('error', 'code-invalid', null, 'FILTER_VALUE_UNKNOWN_CODE',
623
+ this.opContext.i18n.translate('FILTER_VALUE_UNKNOWN_CODE', this.opContext.langs, [value, this.system()]),
624
+ 'invalid-code', 422);
620
625
  }
621
626
  filterContext.filters.push(new ICD11FilterSet('isa', {
622
627
  ancestorId: target.id, minDepth: op === 'is-a' ? 0 : 1, value,
@@ -749,12 +754,24 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
749
754
  }
750
755
  const count = await get('SELECT COUNT(*) n FROM concept WHERE system = ?', [sys.id]);
751
756
  const langs = await get("SELECT value FROM meta WHERE key = 'languages'", []);
757
+ // per-language titles; a database built before system_title existed has none
758
+ const all = (sql, p) => new Promise((res, rej) =>
759
+ db.all(sql, p, (e, r) => e ? rej(e) : res(r || [])));
760
+ const titles = {};
761
+ const hasTitles = await get(
762
+ "SELECT 1 x FROM sqlite_master WHERE type = 'table' AND name = 'system_title'", []);
763
+ if (hasTitles) {
764
+ for (const r of await all('SELECT lang, title FROM system_title WHERE system = ?', [sys.id])) {
765
+ titles[r.lang] = r.title;
766
+ }
767
+ }
752
768
  this._shared = {
753
769
  systemCode: sys.code,
754
770
  systemId: sys.id,
755
771
  url: sys.url,
756
772
  version: sys.version,
757
773
  title: sys.title,
774
+ titles,
758
775
  isTree: !!sys.is_tree,
759
776
  count: count.n,
760
777
  languages: (langs?.value || 'en').split(','),
@@ -232,12 +232,11 @@ async function parseExpression(ctx, text) {
232
232
  */
233
233
  function notOnAnyAxis(ctx, stem, token) {
234
234
  const axes = stem.axes.map(a => a.axis_name).join(', ');
235
- const msg = axes
236
- ? `'${token.text}' is not a permitted value on any postcoordination axis of `
237
- + `'${stem.stem.text}'. That stem declares: ${axes}`
238
- : `'${token.text}' cannot be postcoordinated onto '${stem.stem.text}': that concept `
239
- + `declares no postcoordination axes`;
240
- return new Issue('error', 'code-invalid', null, null, msg, 'invalid-code', 400).handleAsOO(400);
235
+ const [msgId, params] = axes
236
+ ? ['ICD11_PC_VALUE_NOT_ON_AXIS', [token.text, stem.stem.text, axes]]
237
+ : ['ICD11_PC_NO_AXES', [token.text, stem.stem.text]];
238
+ return new Issue('error', 'code-invalid', null, msgId, ctx.translate(msgId, params),
239
+ 'invalid-code', 400).handleAsOO(400);
241
240
  }
242
241
 
243
242
  module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };
@@ -42,6 +42,7 @@ text lives in `designation`. The untouched native JSON for each concept is kept
42
42
  |---|---|
43
43
  | `meta` | key/value: release, languages, endpoints, tool version, request count |
44
44
  | `system` | one row per code system — `mms`, `icf`, `foundation` — with its canonical url, release and `is_tree` |
45
+ | `system_title` | the code system's title per language (`system.title` is the primary one); only titles WHO actually translated |
45
46
  | `concept` | one row per entity |
46
47
  | `concept_parent` | parent/child edges, `seq` preserving the API's order |
47
48
  | `concept_closure` | transitive closure including the reflexive row, so is-a never needs recursion |
@@ -55,6 +55,15 @@ CREATE TABLE IF NOT EXISTS system (
55
55
  uri_stem TEXT NOT NULL -- versioned uri prefix used by the native API
56
56
  );
57
57
 
58
+ -- the code system's own title, per language crawled (system.title is the primary language).
59
+ -- Only a title WHO actually translated is recorded -- an English fallback is not a French title.
60
+ CREATE TABLE IF NOT EXISTS system_title (
61
+ system INTEGER NOT NULL REFERENCES system(id),
62
+ lang TEXT NOT NULL,
63
+ title TEXT NOT NULL,
64
+ PRIMARY KEY (system, lang)
65
+ );
66
+
58
67
  -- one row per concept
59
68
  CREATE TABLE IF NOT EXISTS concept (
60
69
  id INTEGER PRIMARY KEY,
@@ -374,6 +383,8 @@ class Icd11Importer {
374
383
  setMeta: db.prepare(`INSERT INTO meta(key,value) VALUES (?,?)
375
384
  ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
376
385
  getMeta: db.prepare(`SELECT value FROM meta WHERE key=?`),
386
+ insSystemTitle: db.prepare(`INSERT INTO system_title(system,lang,title) VALUES (?,?,?)
387
+ ON CONFLICT(system,lang) DO UPDATE SET title=excluded.title`),
377
388
  insSystem: db.prepare(`INSERT INTO system(code,url,version,title,is_tree,api_root,uri_stem)
378
389
  VALUES (@code,@url,@version,@title,@is_tree,@api_root,@uri_stem)
379
390
  ON CONFLICT(code) DO UPDATE SET
@@ -566,6 +577,8 @@ class Icd11Importer {
566
577
 
567
578
  for (const lang of languages) {
568
579
  const isPrimary = lang === primaryLang;
580
+ // before the resume check: a language whose concepts are all done still needs its title
581
+ await this.storeSystemTitle(systemId, def, lang, isPrimary ? root : null);
569
582
  const done = new Set(this.stmt.doneLangs.all(systemId, lang).map(r => r.uri));
570
583
  const todo = uris.filter(u => !done.has(unversion(u)));
571
584
  if (!todo.length) { this.log(` [${lang}] already complete`); continue; }
@@ -609,6 +622,32 @@ class Icd11Importer {
609
622
  return systemId;
610
623
  }
611
624
 
625
+ /**
626
+ * Record the linearization root's title in one language. `root` is the document already
627
+ * fetched for the primary language; other languages cost one extra call each. The root's
628
+ * title carries the language WHO actually answered in, so an untranslated title (WHO falls
629
+ * back to English) is skipped rather than stored under the requested language.
630
+ */
631
+ async storeSystemTitle(systemId, def, lang, root) {
632
+ if (!root) {
633
+ const r = await this.getEntity(WHO + def.apiRoot, lang);
634
+ if (!r.ok) {
635
+ this.problem('title-failed', r.error, { system: def.code, lang, uri: def.apiRoot });
636
+ this.warn(` ! could not read the ${lang} title of ${def.apiRoot}: ${r.error}`);
637
+ return;
638
+ }
639
+ root = r.body;
640
+ }
641
+ const title = textOf(root.title);
642
+ if (!title) return;
643
+ const got = langOf(root.title, lang);
644
+ if (got.split('-')[0] !== lang.split('-')[0]) {
645
+ this.log(` [${lang}] no translated title (WHO answered in ${got})`);
646
+ return;
647
+ }
648
+ this.stmt.insSystemTitle.run(systemId, lang, title);
649
+ }
650
+
612
651
  /**
613
652
  * Turn the parent and scale-entity uris collected during the crawl into real ids.
614
653
  * `rootUri` is the linearization root, which the top-level concepts name as their
@@ -780,7 +780,7 @@ class RxNormImporter {
780
780
  if (this.options.verbose) console.log('Generating word stems...');
781
781
 
782
782
  // Simple English stemmer implementation
783
- var natural = require('natural');
783
+ var natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
784
784
  const stems = new Map();
785
785
 
786
786
  // Get all RXNORM concepts
@@ -4,7 +4,7 @@ const inquirer = require('inquirer');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
6
  const chalk = require('chalk');
7
- const natural = require('natural');
7
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
8
8
 
9
9
  const {
10
10
  SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
@@ -1,6 +1,6 @@
1
1
  const { LanguagePartType, Languages, Language, LanguageDefinitions} = require('../../library/languages');
2
2
  const {validateParameter, validateOptionalParameter, validateArrayParameter} = require("../../library/utilities");
3
- const natural = require('natural');
3
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
4
4
  /**
5
5
  * Display checking modes for concept designations
6
6
  */
@@ -27,7 +27,7 @@
27
27
  * event loop as it goes, and a search takes milliseconds once it exists.
28
28
  */
29
29
 
30
- const natural = require('natural');
30
+ const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
31
31
 
32
32
  const stemmer = natural.PorterStemmer;
33
33
 
@@ -45,7 +45,7 @@ sources:
45
45
  - npm:us.cdc.phinvads=cdc
46
46
  - npm:hl7.fhir.uv.sdc=sdc
47
47
  - internal:vsac=vsac
48
- - icd11:icd11-2026-01.db
48
+ - icd11:icd11-2026-01-b.db
49
49
 
50
50
  ignored:
51
51
  - urn:iso:std:iso:3166#20210120
@@ -7,7 +7,7 @@
7
7
  // POST /CodeSystem/{id}/$lookup
8
8
  //
9
9
 
10
- const { TerminologyWorker } = require('./worker');
10
+ const { TerminologyWorker, Unknown_Code_in_VersionSCT, SCTVersion } = require('./worker');
11
11
  const { FhirCodeSystemProvider } = require('../cs/cs-cs');
12
12
  const { Designations} = require("../library/designations");
13
13
  const {TxParameters} = require("../params");
@@ -251,13 +251,16 @@ class LookupWorker extends TerminologyWorker {
251
251
  const locateResult = await csProvider.locate(code);
252
252
 
253
253
  if (!locateResult || !locateResult.context) {
254
- let message = `Unable to find code '${code}' in ${csProvider.system()} version ${csProvider.version() || 'unknown'}`
254
+ const system = csProvider.system();
255
+ const version = csProvider.version();
256
+ const msgId = Unknown_Code_in_VersionSCT(system, version);
257
+ let message = this.i18n.translate(msgId, params.HTTPLanguages, [code, system, version, SCTVersion(system, version)]);
255
258
  if (locateResult?.message) {
256
259
  message += ' ('+locateResult.message+')';
257
260
  }
258
261
  // invalid-code, not not-found: the code system was found, the code in it was not.
259
262
  // not-found is for a code system or value set that could not be resolved at all.
260
- throw new Issue('error', 'not-found', null, null, message, 'invalid-code', 404);
263
+ throw new Issue('error', 'not-found', null, msgId, message, 'invalid-code', 404);
261
264
  }
262
265
 
263
266
  const ctxt = locateResult.context;
@@ -268,7 +271,7 @@ class LookupWorker extends TerminologyWorker {
268
271
  // name (required)
269
272
  responseParams.push({
270
273
  name: 'name',
271
- valueString: csProvider.name()
274
+ valueString: csProvider.name(params.workingLanguages())
272
275
  });
273
276
  responseParams.push({
274
277
  name: 'code',