fhirsmith 0.9.0 → 0.9.2

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.
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs').promises;
2
+ const crypto = require('crypto');
2
3
  const sqlite3 = require('sqlite3').verbose();
3
4
  const { VersionUtilities } = require('../../library/version-utilities');
4
5
  const ValueSet = require("../library/valueset");
@@ -18,8 +19,13 @@ class ValueSetDatabase {
18
19
  */
19
20
  constructor(dbPath) {
20
21
  this.dbPath = dbPath;
21
- this._db = null; // Shared read-only connection
22
- this._writeDb = null; // Write connection (opened only when needed)
22
+ // Single read-write connection used for everything. Using a separate
23
+ // OPEN_READONLY connection for reads can miss WAL-based schema changes
24
+ // made through the write connection (because read-only opens can't fully
25
+ // participate in the shared-memory protocol), so queries issued right
26
+ // after a migration ALTER TABLE can fail with a stale schema cache.
27
+ this._writeDb = null;
28
+ this._migrationPromise = null;
23
29
  }
24
30
 
25
31
  /**
@@ -29,46 +35,104 @@ class ValueSetDatabase {
29
35
  * @private
30
36
  */
31
37
  _migrateIfNeeded(db) {
32
- return new Promise((resolve, reject) => {
33
- db.all("PRAGMA table_info(valuesets)", [], (err, cols) => {
34
- if (err) { reject(err); return; }
35
- const hasCol = cols.some(c => c.name === 'date_first_seen');
36
- const migrations = [];
37
- if (!hasCol) {
38
- migrations.push(new Promise((res, rej) => {
38
+ // Run migrations SEQUENTIALLY. node-sqlite3 does not guarantee that
39
+ // separately-submitted statements run in submission order on the same
40
+ // connection `db.serialize()` is opt-in. Without sequencing, a
41
+ // `CREATE INDEX` can race ahead of its `CREATE TABLE`, or a `PRAGMA
42
+ // table_info` can race ahead of a `CREATE TABLE IF NOT EXISTS`, and
43
+ // you get "no such table" errors on DDL that should have been fine.
44
+ const run = (sql) => new Promise((res, rej) => {
45
+ db.run(sql, [], (err) => err ? rej(err) : res());
46
+ });
47
+ const all = (sql) => new Promise((res, rej) => {
48
+ db.all(sql, [], (err, rows) => err ? rej(err) : res(rows));
49
+ });
50
+
51
+ return (async () => {
52
+ const cols = await all("PRAGMA table_info(valuesets)");
53
+ const hasDateFirstSeen = cols.some(c => c.name === 'date_first_seen');
54
+ const hasContentHash = cols.some(c => c.name === 'content_hash');
55
+
56
+ if (!hasDateFirstSeen) {
57
+ await run("ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0");
58
+ }
59
+ if (!hasContentHash) {
60
+ await run("ALTER TABLE valuesets ADD COLUMN content_hash TEXT");
61
+ }
62
+
63
+ // Ensure vsac_runs table exists (with total_updated for fresh installs)
64
+ await run(`
65
+ CREATE TABLE IF NOT EXISTS vsac_runs (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ started_at INTEGER NOT NULL,
68
+ finished_at INTEGER,
69
+ status TEXT NOT NULL DEFAULT 'running',
70
+ error_message TEXT,
71
+ total_fetched INTEGER,
72
+ total_new INTEGER,
73
+ total_updated INTEGER
74
+ )
75
+ `);
76
+
77
+ // If vsac_runs already existed (older schema), add total_updated column
78
+ const runCols = await all("PRAGMA table_info(vsac_runs)");
79
+ const hasTotalUpdated = runCols.some(c => c.name === 'total_updated');
80
+ if (!hasTotalUpdated && runCols.length > 0) {
81
+ await run("ALTER TABLE vsac_runs ADD COLUMN total_updated INTEGER");
82
+ }
83
+
84
+ // Ensure vsac_settings table exists (for _lastUpdated tracking etc.)
85
+ await run(`
86
+ CREATE TABLE IF NOT EXISTS vsac_settings (
87
+ key TEXT PRIMARY KEY,
88
+ value TEXT
89
+ )
90
+ `);
91
+
92
+ // Ensure vsac_events table exists (audit log of new/updated/deleted value sets)
93
+ await run(`
94
+ CREATE TABLE IF NOT EXISTS vsac_events (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ timestamp INTEGER NOT NULL,
97
+ event_type TEXT NOT NULL,
98
+ url TEXT NOT NULL,
99
+ version TEXT,
100
+ detail TEXT
101
+ )
102
+ `);
103
+ await run("CREATE INDEX IF NOT EXISTS idx_events_timestamp ON vsac_events(timestamp)");
104
+
105
+ // Backfill content_hash for any existing rows that don't have one.
106
+ // This establishes a baseline so the NEXT sync can detect real content
107
+ // changes immediately (otherwise the first sync just silently populates
108
+ // hashes and can never flag anything as 'updated').
109
+ const needHash = await all(
110
+ "SELECT COUNT(*) AS n FROM valuesets WHERE content_hash IS NULL"
111
+ );
112
+ const missing = (needHash[0] && needHash[0].n) || 0;
113
+ if (missing > 0) {
114
+ console.log(`Backfilling content_hash for ${missing} existing value sets...`);
115
+ const rows = await all(
116
+ "SELECT id, content FROM valuesets WHERE content_hash IS NULL"
117
+ );
118
+ let done = 0;
119
+ for (const row of rows) {
120
+ const hash = crypto.createHash('sha256').update(row.content).digest('hex');
121
+ await new Promise((res, rej) => {
39
122
  db.run(
40
- "ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0",
41
- [],
123
+ 'UPDATE valuesets SET content_hash = ? WHERE id = ?',
124
+ [hash, row.id],
42
125
  (err) => err ? rej(err) : res()
43
126
  );
44
- }));
127
+ });
128
+ done++;
129
+ if (done % 1000 === 0) {
130
+ console.log(` ...${done}/${missing}`);
131
+ }
45
132
  }
46
- // Ensure vsac_runs table exists
47
- migrations.push(new Promise((res, rej) => {
48
- db.run(`
49
- CREATE TABLE IF NOT EXISTS vsac_runs (
50
- id INTEGER PRIMARY KEY AUTOINCREMENT,
51
- started_at INTEGER NOT NULL,
52
- finished_at INTEGER,
53
- status TEXT NOT NULL DEFAULT 'running',
54
- error_message TEXT,
55
- total_fetched INTEGER,
56
- total_new INTEGER
57
- )
58
- `, [], (err) => err ? rej(err) : res());
59
- }));
60
- // Ensure vsac_settings table exists (for _lastUpdated tracking etc.)
61
- migrations.push(new Promise((res, rej) => {
62
- db.run(`
63
- CREATE TABLE IF NOT EXISTS vsac_settings (
64
- key TEXT PRIMARY KEY,
65
- value TEXT
66
- )
67
- `, [], (err) => err ? rej(err) : res());
68
- }));
69
- Promise.all(migrations).then(() => resolve()).catch(reject);
70
- });
71
- });
133
+ console.log(`Backfilled ${done} hashes.`);
134
+ }
135
+ })();
72
136
  }
73
137
 
74
138
  /**
@@ -77,21 +141,9 @@ class ValueSetDatabase {
77
141
  * @private
78
142
  */
79
143
  _getReadConnection() {
80
- return new Promise((resolve, reject) => {
81
- if (this._db) {
82
- resolve(this._db);
83
- return;
84
- }
85
-
86
- this._db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
87
- if (err) {
88
- this._db = null;
89
- reject(new Error(`Failed to open database ${this.dbPath}: ${err.message}`));
90
- } else {
91
- resolve(this._db);
92
- }
93
- });
94
- });
144
+ // Reads go through the same connection as writes. See the constructor
145
+ // comment for why we don't use a separate OPEN_READONLY connection.
146
+ return this._ensureMigrated().then(() => this._writeDb);
95
147
  }
96
148
 
97
149
  /**
@@ -100,21 +152,38 @@ class ValueSetDatabase {
100
152
  * @private
101
153
  */
102
154
  _getWriteConnection() {
103
- return new Promise((resolve, reject) => {
155
+ return this._ensureMigrated().then(() => this._writeDb);
156
+ }
157
+
158
+ /**
159
+ * Ensure the database schema is migrated. Idempotent: subsequent calls
160
+ * return the cached promise. Opens a write connection (which is required
161
+ * for ALTER TABLE) if one is not already open. The write connection is
162
+ * kept open for reuse by later _getWriteConnection calls.
163
+ * @returns {Promise<void>}
164
+ * @private
165
+ */
166
+ _ensureMigrated() {
167
+ if (this._migrationPromise) {
168
+ return this._migrationPromise;
169
+ }
170
+ this._migrationPromise = new Promise((resolve, reject) => {
104
171
  if (this._writeDb) {
105
- resolve(this._writeDb);
172
+ this._migrateIfNeeded(this._writeDb).then(resolve).catch(reject);
106
173
  return;
107
174
  }
108
-
109
175
  this._writeDb = new sqlite3.Database(this.dbPath, (err) => {
110
176
  if (err) {
111
177
  this._writeDb = null;
112
178
  reject(new Error(`Failed to open database for writing: ${err.message}`));
113
- } else {
114
- this._migrateIfNeeded(this._writeDb).then(() => resolve(this._writeDb)).catch(reject);
179
+ return;
115
180
  }
181
+ this._migrateIfNeeded(this._writeDb).then(resolve).catch(reject);
116
182
  });
117
183
  });
184
+ // If migration fails, clear the cached promise so a retry can attempt again
185
+ this._migrationPromise.catch(() => { this._migrationPromise = null; });
186
+ return this._migrationPromise;
118
187
  }
119
188
 
120
189
  /**
@@ -122,29 +191,20 @@ class ValueSetDatabase {
122
191
  * @returns {Promise<void>}
123
192
  */
124
193
  async close() {
125
- const closePromises = [];
126
-
127
- if (this._db) {
128
- closePromises.push(new Promise((resolve) => {
129
- this._db.close((err) => {
130
- if (err) console.warn(`Warning closing read connection: ${err.message}`);
131
- this._db = null;
132
- resolve();
133
- });
134
- }));
135
- }
194
+ // Clear the cached migration promise so a subsequent open re-migrates
195
+ this._migrationPromise = null;
136
196
 
137
- if (this._writeDb) {
138
- closePromises.push(new Promise((resolve) => {
139
- this._writeDb.close((err) => {
140
- if (err) console.warn(`Warning closing write connection: ${err.message}`);
141
- this._writeDb = null;
142
- resolve();
143
- });
144
- }));
197
+ if (!this._writeDb) {
198
+ return;
145
199
  }
146
200
 
147
- await Promise.all(closePromises);
201
+ await new Promise((resolve) => {
202
+ this._writeDb.close((err) => {
203
+ if (err) console.warn(`Warning closing database connection: ${err.message}`);
204
+ this._writeDb = null;
205
+ resolve();
206
+ });
207
+ });
148
208
  }
149
209
 
150
210
  /**
@@ -193,6 +253,7 @@ class ValueSetDatabase {
193
253
  status TEXT,
194
254
  title TEXT,
195
255
  content TEXT NOT NULL,
256
+ content_hash TEXT,
196
257
  last_seen INTEGER DEFAULT (strftime('%s', 'now')),
197
258
  date_first_seen INTEGER DEFAULT (strftime('%s', 'now'))
198
259
  )
@@ -241,17 +302,31 @@ class ValueSetDatabase {
241
302
  status TEXT NOT NULL DEFAULT 'running',
242
303
  error_message TEXT,
243
304
  total_fetched INTEGER,
244
- total_new INTEGER
305
+ total_new INTEGER,
306
+ total_updated INTEGER
245
307
  )
246
308
  `);
247
309
 
248
310
  // Settings table (key-value store for _lastUpdated tracking etc.)
249
311
  db.run(`
250
- CREATE TABLE IF NOT EXISTS vsac_settings (
251
- key TEXT PRIMARY KEY,
252
- value TEXT
253
- )
312
+ CREATE TABLE IF NOT EXISTS vsac_settings (
313
+ key TEXT PRIMARY KEY,
314
+ value TEXT
315
+ )
316
+ `);
317
+
318
+ // Event log table (new/updated/deleted value sets)
319
+ db.run(`
320
+ CREATE TABLE IF NOT EXISTS vsac_events (
321
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
322
+ timestamp INTEGER NOT NULL,
323
+ event_type TEXT NOT NULL,
324
+ url TEXT NOT NULL,
325
+ version TEXT,
326
+ detail TEXT
327
+ )
254
328
  `);
329
+ db.run('CREATE INDEX idx_events_timestamp ON vsac_events(timestamp)');
255
330
 
256
331
  // Create indexes for better search performance
257
332
  db.run('CREATE INDEX idx_valuesets_url ON valuesets(url, version)');
@@ -300,15 +375,36 @@ class ValueSetDatabase {
300
375
  * @param {number} id - The run ID from startRun()
301
376
  * @param {number} totalFetched - Total value sets fetched
302
377
  * @param {number} totalNew - Number of new value sets found
378
+ * @param {number} [totalUpdated=0] - Number of existing value sets whose content changed
303
379
  * @returns {Promise<void>}
304
380
  */
305
- async finishRun(id, totalFetched, totalNew) {
381
+ async finishRun(id, totalFetched, totalNew, totalUpdated = 0) {
306
382
  const db = await this._getWriteConnection();
307
383
  return new Promise((resolve, reject) => {
308
384
  db.run(
309
385
  `UPDATE vsac_runs SET finished_at = strftime('%s','now'), status = 'ok',
310
- total_fetched = ?, total_new = ? WHERE id = ?`,
311
- [totalFetched, totalNew, id],
386
+ total_fetched = ?, total_new = ?, total_updated = ? WHERE id = ?`,
387
+ [totalFetched, totalNew, totalUpdated, id],
388
+ err => err ? reject(err) : resolve()
389
+ );
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Record a VSAC event in the audit log
395
+ * @param {string} eventType - 'new', 'updated', or 'deleted'
396
+ * @param {string} url - The value set URL
397
+ * @param {string|null} version - The version, or null
398
+ * @param {string|null} [detail] - Optional detail string
399
+ * @returns {Promise<void>}
400
+ */
401
+ async recordEvent(eventType, url, version, detail = null) {
402
+ const db = await this._getWriteConnection();
403
+ return new Promise((resolve, reject) => {
404
+ db.run(
405
+ `INSERT INTO vsac_events (timestamp, event_type, url, version, detail)
406
+ VALUES (strftime('%s','now'), ?, ?, ?, ?)`,
407
+ [eventType, url, version || null, detail],
312
408
  err => err ? reject(err) : resolve()
313
409
  );
314
410
  });
@@ -358,7 +454,7 @@ class ValueSetDatabase {
358
454
  return new Promise((resolve, reject) => {
359
455
  db.run(
360
456
  `INSERT INTO vsac_settings (key, value) VALUES (?, ?)
361
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
457
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
362
458
  [key, value],
363
459
  err => err ? reject(err) : resolve()
364
460
  );
@@ -368,9 +464,10 @@ class ValueSetDatabase {
368
464
  /**
369
465
  * Insert or update a single ValueSet in the database
370
466
  * @param {Object} valueSet - The ValueSet resource
467
+ * @param {string} [contentHash] - Optional pre-computed content hash to store
371
468
  * @returns {Promise<void>}
372
469
  */
373
- async upsertValueSet(valueSet) {
470
+ async upsertValueSet(valueSet, contentHash = null) {
374
471
  if (!valueSet.url) {
375
472
  throw new Error('ValueSet must have a url property');
376
473
  }
@@ -405,8 +502,9 @@ class ValueSetDatabase {
405
502
  db.run(`
406
503
  INSERT INTO valuesets (
407
504
  id, url, version, date, description, effectivePeriod_start, effectivePeriod_end,
408
- expansion_identifier, name, publisher, status, title, content, last_seen, date_first_seen
409
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
505
+ expansion_identifier, name, publisher, status, title, content, content_hash,
506
+ last_seen, date_first_seen
507
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
410
508
  ON CONFLICT(id) DO UPDATE SET
411
509
  url=excluded.url,
412
510
  version=excluded.version,
@@ -420,6 +518,7 @@ class ValueSetDatabase {
420
518
  status=excluded.status,
421
519
  title=excluded.title,
422
520
  content=excluded.content,
521
+ content_hash=excluded.content_hash,
423
522
  last_seen=strftime('%s', 'now')
424
523
  `, [
425
524
  valueSet.id,
@@ -434,7 +533,8 @@ class ValueSetDatabase {
434
533
  valueSet.publisher || null,
435
534
  valueSet.status || null,
436
535
  valueSet.title || null,
437
- JSON.stringify(valueSet)
536
+ JSON.stringify(valueSet),
537
+ contentHash
438
538
  ], (err) => {
439
539
  if (err) {
440
540
  reject(new Error(`Failed to insert main record: ${err.message}`));
@@ -450,6 +550,24 @@ class ValueSetDatabase {
450
550
  });
451
551
  }
452
552
 
553
+ /**
554
+ * Backfill the content_hash column for a row without rewriting content or
555
+ * emitting an event. Used for legacy rows from before content_hash existed.
556
+ * @param {string} id - The ValueSet id
557
+ * @param {string} hash - The SHA-256 hex hash to store
558
+ * @returns {Promise<void>}
559
+ */
560
+ async setContentHash(id, hash) {
561
+ const db = await this._getWriteConnection();
562
+ return new Promise((resolve, reject) => {
563
+ db.run(
564
+ 'UPDATE valuesets SET content_hash = ? WHERE id = ?',
565
+ [hash, id],
566
+ err => err ? reject(err) : resolve()
567
+ );
568
+ });
569
+ }
570
+
453
571
  /**
454
572
  * Just update the timestamp on the valueset
455
573
  * @param {Object} valueSet - The ValueSet resource
@@ -590,7 +708,7 @@ class ValueSetDatabase {
590
708
  const db = await this._getReadConnection();
591
709
 
592
710
  return new Promise((resolve, reject) => {
593
- db.all('SELECT id, url, version, content FROM valuesets', [], (err, rows) => {
711
+ db.all('SELECT id, url, version, content, content_hash FROM valuesets', [], (err, rows) => {
594
712
  if (err) {
595
713
  reject(new Error(`Failed to load value sets: ${err.message}`));
596
714
  return;
@@ -603,6 +721,9 @@ class ValueSetDatabase {
603
721
  for (const row of rows) {
604
722
  const valueSet = new ValueSet(JSON.parse(row.content));
605
723
  valueSet.sourcePackage = source;
724
+ // Attach the stored content hash so callers can detect changes
725
+ // without recomputing over the full JSON.
726
+ valueSet.contentHash = row.content_hash || null;
606
727
  // Store by URL and id alone
607
728
  this.addToMap(valueSetMap, row.id, row.url, row.version, valueSet);
608
729
  }