fhirsmith 0.9.5 → 0.9.6

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 CHANGED
@@ -6,11 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [v0.9.6] - 2026-05-21
10
+
11
+ ### Added
12
+
13
+ - increase timeout when publishing IGs (for US Core)
14
+
15
+ ### Fixed
16
+
17
+ - Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak
18
+ - Fix async problem loading OMOP
19
+ - Fix memory leaks
20
+ - Don't leak database connections
21
+
22
+ ### Tx Conformance Statement
23
+
24
+ FHIRsmith passed all 1649 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.7)
25
+
9
26
  ## [v0.9.5] - 2026-05-16
10
27
 
11
28
  ### Fixed
12
29
 
13
30
  - Workaround for memory leak in re2-wasm library that reduces it's severity
31
+ - Replace re2-wasm with re2js in library/regex-utilities.js to eliminate the underlying WASM-heap leak
14
32
 
15
33
  ### Tx Conformance Statement
16
34
 
@@ -63,7 +63,8 @@
63
63
  "enabled": true,
64
64
  "database": "/absolute/path/to/publisher.db",
65
65
  "sessionSecret": "change-this-to-a-secure-random-string",
66
- "workspaceRoot": "/absolute/path/to/workspaces"
66
+ "workspaceRoot": "/absolute/path/to/workspaces",
67
+ "igPublisherTimeoutMinutes": 60
67
68
  },
68
69
  "npmprojector": {
69
70
  "enabled": true,
@@ -1,27 +1,64 @@
1
- const { RE2 } = require('re2-wasm');
1
+ const { RE2JS } = require('re2js');
2
+
3
+ // Translate a JS-style flags string ("i", "im", "is", etc.) into the bit-flag
4
+ // integer that RE2JS.compile() expects. Only the flags actually used by callers
5
+ // (and their natural counterparts) are mapped; anything else is ignored.
6
+ function toRE2JSFlags(flags) {
7
+ if (!flags) return 0;
8
+ let bits = 0;
9
+ if (flags.includes('i')) bits |= RE2JS.CASE_INSENSITIVE;
10
+ if (flags.includes('m')) bits |= RE2JS.MULTILINE;
11
+ if (flags.includes('s')) bits |= RE2JS.DOTALL;
12
+ // The 'u' flag was a re2-wasm workaround; re2js is pure JS and handles
13
+ // unicode natively, so it's a no-op here.
14
+ return bits;
15
+ }
16
+
17
+ // Thin wrapper that exposes the only method the rest of the codebase uses
18
+ // against compiled regexes: a JS-RegExp-style `.test(input)` that returns
19
+ // true if the pattern is found anywhere in `input`.
20
+ class CompiledRegex {
21
+ constructor(pattern, flags) {
22
+ this._pattern = RE2JS.compile(pattern, toRE2JSFlags(flags));
23
+ }
24
+
25
+ test(input) {
26
+ if (input == null) return false;
27
+ return this._pattern.matcher(String(input)).find();
28
+ }
29
+ }
2
30
 
3
31
  class RegExUtilities {
4
32
 
33
+ // re2js doesn't have re2-wasm's WASM-heap leak, so the cache here is
34
+ // purely a performance optimisation. We cap it with a simple LRU so a
35
+ // workload with many distinct regex patterns can't grow it without bound.
36
+ static MAX_CACHE_SIZE = 1000;
37
+
5
38
  constructor() {
6
39
  this._cache = new Map();
7
40
  }
8
41
 
9
42
  compile(pattern, flags) {
10
- // re2-wasm has a fixed 16 MB WASM heap with no real free(): every
11
- // new RE2(...) permanently consumes a few KB. Cache by (pattern, flags)
12
- // so the same regex compiles at most once.
13
- // TODO: replace re2-wasm with native re2 to eliminate the underlying leak.
14
- const re2Flags = flags && flags.includes('u') ? flags : (flags || '') + 'u';
15
- const key = pattern + '|' + re2Flags;
16
- let compiled = this._cache.get(key);
17
- if (!compiled) {
18
- compiled = new RE2(pattern, re2Flags);
19
- this._cache.set(key, compiled);
43
+ const key = pattern + '|' + (flags || '');
44
+ const cached = this._cache.get(key);
45
+ if (cached) {
46
+ // Move to most-recently-used position by re-inserting.
47
+ this._cache.delete(key);
48
+ this._cache.set(key, cached);
49
+ return cached;
50
+ }
51
+ const compiled = new CompiledRegex(pattern, flags);
52
+ if (this._cache.size >= RegExUtilities.MAX_CACHE_SIZE) {
53
+ // Evict the oldest entry. Map iteration order is insertion order, so
54
+ // the first key is the least-recently-used.
55
+ const oldest = this._cache.keys().next().value;
56
+ this._cache.delete(oldest);
20
57
  }
58
+ this._cache.set(key, compiled);
21
59
  return compiled;
22
60
  }
23
61
 
24
62
  }
25
63
 
26
64
  module.exports = new RegExUtilities();
27
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "txVersion": "1.9.1",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -61,7 +61,7 @@
61
61
  "passport-github2": "^0.1.12",
62
62
  "passport-google-oauth20": "^2.0.0",
63
63
  "properties-file": "^3.6.4",
64
- "re2-wasm": "^1.0.2",
64
+ "re2js": "^2.8.0",
65
65
  "rimraf": "^5.0.10",
66
66
  "sqlite3": "^5.1.7",
67
67
  "tar": "^7.5.7",
@@ -649,13 +649,14 @@ class PublisherModule {
649
649
  reject(error);
650
650
  });
651
651
 
652
- // Timeout after 30 minutes
652
+ // Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
653
+ const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
653
654
  const timeout = setTimeout(async () => {
654
655
  java.kill();
655
656
  logStream.end();
656
- await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
657
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
657
658
  reject(new Error('IG Publisher timed out'));
658
- }, 30 * 60 * 1000);
659
+ }, timeoutMinutes * 60 * 1000);
659
660
 
660
661
  java.on('close', () => {
661
662
  clearTimeout(timeout);
@@ -191,6 +191,7 @@ class DraftTaskProcessor {
191
191
  stdio: ['pipe', 'pipe', 'pipe']
192
192
  });
193
193
 
194
+ // eslint-disable-next-line no-unused-vars
194
195
  let stdout = '';
195
196
  let stderr = '';
196
197
 
@@ -255,6 +256,7 @@ class DraftTaskProcessor {
255
256
  stdio: ['pipe', 'pipe', 'pipe']
256
257
  });
257
258
 
259
+ // eslint-disable-next-line no-unused-vars
258
260
  let stdout = '';
259
261
  let stderr = '';
260
262
 
@@ -316,6 +318,7 @@ class DraftTaskProcessor {
316
318
  });
317
319
 
318
320
  let stdout = '';
321
+ // eslint-disable-next-line no-unused-vars
319
322
  let stderr = '';
320
323
 
321
324
  sushi.stdout.on('data', (data) => {
@@ -382,6 +385,7 @@ class DraftTaskProcessor {
382
385
  logStream.write('Working Directory: ' + draftDir + '\n');
383
386
  logStream.write('=====================================\n\n');
384
387
 
388
+ // eslint-disable-next-line no-unused-vars
385
389
  let hasOutput = false;
386
390
  let lastProgressUpdate = Date.now();
387
391
 
@@ -433,7 +437,8 @@ class DraftTaskProcessor {
433
437
  reject(error);
434
438
  });
435
439
 
436
- // Timeout after 30 minutes
440
+ // Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
441
+ const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
437
442
  const timeout = setTimeout(async () => {
438
443
  java.kill('SIGTERM'); // Try graceful shutdown first
439
444
 
@@ -442,11 +447,11 @@ class DraftTaskProcessor {
442
447
  java.kill('SIGKILL');
443
448
  }, 10000);
444
449
 
445
- logStream.write('\nTIMEOUT: Process killed after 30 minutes\n');
450
+ logStream.write('\nTIMEOUT: Process killed after ' + timeoutMinutes + ' minutes\n');
446
451
  logStream.end();
447
- await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
452
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
448
453
  reject(new Error('IG Publisher timed out'));
449
- }, 30 * 60 * 1000);
454
+ }, timeoutMinutes * 60 * 1000);
450
455
 
451
456
  java.on('close', () => {
452
457
  clearTimeout(timeout);
@@ -1,14 +1,15 @@
1
- // Manual reproducer for the re2-wasm WASM heap leak.
2
- // See library/regex-utilities.js for the workaround.
1
+ // Historical reproducer for the re2-wasm WASM heap leak.
2
+ // regex-utilities.js now sits on top of re2js (a pure-JS RE2 port), so the
3
+ // underlying leak is gone. Kept as a stress test for the compile cache:
3
4
  //
4
5
  // node test-scripts/repro-re2-wasm-leak.js # same-pattern stress
5
6
  // node test-scripts/repro-re2-wasm-leak.js --unique # unique-pattern stress
6
7
  //
7
- // Without the cache, same-pattern OOMs at ~2965 iterations.
8
- // With the cache, same-pattern runs indefinitely.
9
- // Unique-pattern still OOMs (each pattern is a real compile and the underlying
10
- // re2-wasm heap leak still applies). A proper fix is to replace re2-wasm with
11
- // the native `re2` package.
8
+ // Background (re2-wasm era): without the cache, same-pattern OOMed at ~2965
9
+ // iterations; with the cache, same-pattern ran indefinitely but unique-pattern
10
+ // still OOMed because every distinct pattern was a real compile and re2-wasm
11
+ // could not free its WASM heap. Under re2js both modes now run indefinitely
12
+ // (unique-pattern is bounded only by ordinary V8 heap growth from the cache).
12
13
 
13
14
  const re = require('../library/regex-utilities');
14
15
 
package/tx/cs/cs-loinc.js CHANGED
@@ -1429,8 +1429,8 @@ class LoincServicesFactory extends CodeSystemFactoryProvider {
1429
1429
  * @returns {Promise<Array>} Array of {code, display} objects
1430
1430
  */
1431
1431
  async #getAnswerListConcepts(sourceKey) {
1432
- return new Promise((resolve, reject) => {
1433
- let db = new sqlite3.Database(this.dbPath);
1432
+ const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
1433
+ try {
1434
1434
  const sql = `
1435
1435
  SELECT Code, Description
1436
1436
  FROM Relationships, Codes
@@ -1439,17 +1439,17 @@ class LoincServicesFactory extends CodeSystemFactoryProvider {
1439
1439
  AND Relationships.TargetKey = Codes.CodeKey
1440
1440
  `;
1441
1441
 
1442
- db.all(sql, [sourceKey], (err, rows) => {
1443
- if (err) {
1444
- reject(err);
1445
- } else {
1446
- const concepts = rows.map(row => ({
1447
- code: row.Code
1448
- }));
1449
- resolve(concepts);
1450
- }
1442
+ const rows = await new Promise((resolve, reject) => {
1443
+ db.all(sql, [sourceKey], (err, result) => {
1444
+ if (err) reject(err);
1445
+ else resolve(result);
1446
+ });
1451
1447
  });
1452
- });
1448
+
1449
+ return rows.map(row => ({ code: row.Code }));
1450
+ } finally {
1451
+ await new Promise((resolve) => db.close(() => resolve()));
1452
+ }
1453
1453
  }
1454
1454
 
1455
1455
  id() {
package/tx/cs/cs-omop.js CHANGED
@@ -906,41 +906,42 @@ class OMOPServicesFactory extends CodeSystemFactoryProvider {
906
906
  return new OMOPServices(opContext, supplements, db, this._sharedData);
907
907
  }
908
908
 
909
- static checkDB(dbPath) {
909
+ static async checkDB(dbPath) {
910
+ const fs = require('fs');
910
911
  try {
911
- const fs = require('fs');
912
-
913
- // Check if file exists
914
912
  if (!fs.existsSync(dbPath)) {
915
913
  return 'Database file not found';
916
914
  }
917
-
918
- // Check file size
919
915
  const stats = fs.statSync(dbPath);
920
916
  if (stats.size < 1024) {
921
917
  return 'Database file too small';
922
918
  }
919
+ } catch (e) {
920
+ return `Database error: ${e.message}`;
921
+ }
923
922
 
924
- // Try to open database and check for required tables
925
- const db = new sqlite3.Database(dbPath);
926
-
927
- try {
928
- // Simple count query to verify database integrity
929
- db.get('SELECT COUNT(*) as count FROM Concepts', (err) => {
930
- if (err) {
931
- db.close();
932
- return 'Missing Tables - needs re-importing (by java)';
933
- }
934
- });
935
-
936
- db.close();
937
- return 'OK (check via provider for count)';
938
- } catch (e) {
939
- return 'Missing Tables - needs re-importing (by java)';
940
- }
923
+ let db;
924
+ try {
925
+ db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY);
941
926
  } catch (e) {
942
927
  return `Database error: ${e.message}`;
943
928
  }
929
+
930
+ try {
931
+ // Simple count query to verify database integrity. If the Concepts
932
+ // table is missing, db.get rejects and we fall through to the catch.
933
+ const row = await new Promise((resolve, reject) => {
934
+ db.get('SELECT COUNT(*) as count FROM Concepts', (err, result) => {
935
+ if (err) reject(err);
936
+ else resolve(result);
937
+ });
938
+ });
939
+ return `OK (${row && row.count != null ? row.count : 0} Concepts)`;
940
+ } catch (_e) {
941
+ return 'Missing Tables - needs re-importing (by java)';
942
+ } finally {
943
+ await new Promise((resolve) => db.close(() => resolve()));
944
+ }
944
945
  }
945
946
 
946
947
 
package/tx/cs/cs-unii.js CHANGED
@@ -210,18 +210,18 @@ class UniiServicesFactory extends CodeSystemFactoryProvider {
210
210
  }
211
211
 
212
212
  async load() {
213
- let db = new sqlite3.Database(this.dbPath);
214
-
215
- return new Promise((resolve, reject) => {
216
- db.get('SELECT Version FROM UniiVersion', (err, row) => {
217
- if (err) {
218
- reject(new Error(err));
219
- } else {
220
- this._version = row ? row.Version : 'unknown';
221
- resolve(); // This resolves the Promise
222
- }
213
+ const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
214
+ try {
215
+ const row = await new Promise((resolve, reject) => {
216
+ db.get('SELECT Version FROM UniiVersion', (err, result) => {
217
+ if (err) reject(new Error(err));
218
+ else resolve(result);
219
+ });
223
220
  });
224
- });
221
+ this._version = row ? row.Version : 'unknown';
222
+ } finally {
223
+ await new Promise((resolve) => db.close(() => resolve()));
224
+ }
225
225
  }
226
226
 
227
227
  defaultVersion() {
@@ -448,6 +448,11 @@ class OperationContext {
448
448
  this.resourceCache = resourceCache;
449
449
  this.expansionCache = expansionCache;
450
450
  this.debugging = isDebugging();
451
+ // Providers opened during this operation that need their underlying
452
+ // resources (sqlite connections, etc.) released when the operation ends.
453
+ // Shared by reference with copy()'d contexts so a sub-operation's
454
+ // providers are cleaned up by the parent request's closeProviders().
455
+ this._openProviders = [];
451
456
 
452
457
  this.timeTracker.step('tx-op');
453
458
  }
@@ -476,6 +481,9 @@ class OperationContext {
476
481
  newContext.logEntries = [...this.logEntries];
477
482
  newContext.debugging = this.debugging;
478
483
  newContext.usageTracker = this.usageTracker;
484
+ // Share the same provider-cleanup list so providers opened by the copy
485
+ // are released when the parent operation ends.
486
+ newContext._openProviders = this._openProviders;
479
487
  return newContext;
480
488
  }
481
489
 
@@ -624,6 +632,37 @@ class OperationContext {
624
632
  return this.id;
625
633
  }
626
634
 
635
+ /**
636
+ * Register a code-system provider whose resources (typically a sqlite
637
+ * connection opened by factory.build()) should be released when the
638
+ * operation ends. Providers without a close() method are ignored.
639
+ * @param {Object} provider - The provider returned from factory.build()
640
+ */
641
+ registerProvider(provider) {
642
+ if (provider && typeof provider.close === 'function') {
643
+ this._openProviders.push(provider);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Close every provider registered during this operation. Safe to call
649
+ * multiple times — the list is cleared after the first call. Errors
650
+ * from individual close() calls are swallowed so one bad provider can't
651
+ * prevent the others from releasing their resources.
652
+ */
653
+ async closeProviders() {
654
+ if (!this._openProviders || this._openProviders.length === 0) return;
655
+ const providers = this._openProviders;
656
+ this._openProviders = [];
657
+ for (const p of providers) {
658
+ try {
659
+ await p.close();
660
+ } catch (_e) {
661
+ // Swallow — provider cleanup is best-effort.
662
+ }
663
+ }
664
+ }
665
+
627
666
  /**
628
667
  * @type {Languages} languages specified in request
629
668
  */
package/tx/problems.js CHANGED
@@ -2,10 +2,6 @@ const escape = require('escape-html');
2
2
 
3
3
  class ProblemFinder {
4
4
 
5
- constructor() {
6
- this.map = new Map();
7
- }
8
-
9
5
  async scanValueSets(provider) {
10
6
  let unknownVersions = {}; // system -> Set of versions not known to the server
11
7
  for (let vsp of provider.valueSetProviders) {
package/tx/provider.js CHANGED
@@ -419,6 +419,7 @@ class Provider {
419
419
  }
420
420
  if (factory != null) {
421
421
  const csp = await factory.build(opContext, []);
422
+ opContext.registerProvider(csp);
422
423
  const c = csp ? csp.locate(code) : null;
423
424
  if (c) {
424
425
  if (factory.iteratable()) {
package/tx/tx.js CHANGED
@@ -306,6 +306,18 @@ class TXModule {
306
306
  req.txI18n = this.i18n;
307
307
  req.txLog = this.log;
308
308
 
309
+ // Release any code-system providers that opened sqlite connections
310
+ // during this request. closeProviders() is idempotent so it's safe
311
+ // for both events to fire. Listeners are sync; the close itself
312
+ // runs fire-and-forget on the event loop.
313
+ const releaseProviders = () => {
314
+ opContext.closeProviders().catch((err) => {
315
+ try { this.log.warn(`closeProviders failed: ${err && err.message}`); } catch (_) { /* ignore */ }
316
+ });
317
+ };
318
+ res.on('finish', releaseProviders);
319
+ res.on('close', releaseProviders);
320
+
309
321
  // Add X-Request-Id header to response
310
322
  res.setHeader('X-Request-Id', requestId);
311
323
 
@@ -495,6 +495,7 @@ class TranslateWorker extends TerminologyWorker {
495
495
  let result = false;
496
496
  const factory = cm.jsonObj.internalSource;
497
497
  let prov = await factory.build(this.opContext, []);
498
+ this.opContext.registerProvider(prov);
498
499
 
499
500
  output.push({
500
501
  name: 'used-system',
@@ -184,6 +184,10 @@ class TerminologyWorker {
184
184
  if (checkVer) {
185
185
  this.checkVersion(url, provider.version(), params, provider.versionAlgorithm(), op);
186
186
  }
187
+ // Track for lifecycle cleanup at end of request. Providers built from
188
+ // a factory open a fresh sqlite connection that needs releasing;
189
+ // resource-built providers and others without close() are no-ops.
190
+ this.opContext.registerProvider(provider);
187
191
  }
188
192
 
189
193
  return provider;