fhirsmith 0.18.2 → 1.0.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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
package/tx/vs/vs-vsac.js CHANGED
@@ -7,13 +7,116 @@ const { VersionUtilities } = require('../../library/version-utilities');
7
7
  const folders = require('../../library/folder-setup');
8
8
  const {debugLog} = require("../operation-context");
9
9
  const { applyIdPrefix, stripIdPrefix } = require("../library/resource-ids");
10
+ // the server log ({vsac}): sync progress for each value set and page is at debug level
11
+ const vsacLog = require('../../library/logger').getInstance().child({ module: 'vsac' });
10
12
 
11
13
  // Persisted watermark for the phase-1b _lastUpdated scan.
12
14
  const VSAC_LAST_UPDATED_KEY = 'vsac_last_updated_date';
15
+ // Phase-2 work list of the sync in progress, and how far through it the sync has got.
16
+ // Both are cleared when a run completes; if they are still there, the last run was
17
+ // interrupted (server stopped) or failed, and the next run picks up what it didn't finish.
18
+ const VSAC_PENDING_QUEUE_KEY = 'vsac_pending_queue';
19
+ const VSAC_PENDING_PROGRESS_KEY = 'vsac_pending_progress';
20
+
21
+ // The longest delay a Node timer accepts; a longer one fires after 1 ms.
22
+ const MAX_TIMER_MS = 2 ** 31 - 1;
23
+
24
+ // A follower (cluster.md) compares its generation with the database's at most this often,
25
+ // when a request uses the value sets
26
+ const GENERATION_CHECK_MS = 1000;
13
27
 
14
28
  // Canonical URL prefix for VSAC value sets, so operators can enter a bare OID.
15
29
  const VSAC_VALUESET_URL_PREFIX = 'http://cts.nlm.nih.gov/fhir/ValueSet/';
16
30
 
31
+ // Default size of the cache of recently used value sets (see RecentValueSets)
32
+ const DEFAULT_VALUESET_CACHE_MB = 64;
33
+
34
+ /**
35
+ * The value sets most recently read from the database, parsed and prepared, by row id, with
36
+ * the least recently used dropped first once the stored JSON they were read from adds up to
37
+ * more than maxBytes.
38
+ *
39
+ * Reading a value set means parsing its JSON every time: 15ms for a list of 33,000 codes, and
40
+ * $batch-validate-code reads it once per entry. Most use is of a few value sets over and over,
41
+ * so the ones in use are kept. The sync drops a row from here when it writes it, so what is
42
+ * kept is never older than the database (each keeps the contentHash it was read with).
43
+ *
44
+ * What is kept is shared between requests (as the map was): it must not be changed.
45
+ */
46
+ class RecentValueSets {
47
+ constructor(maxBytes) {
48
+ this.maxBytes = maxBytes;
49
+ this.entries = new Map(); // id -> { vs, bytes }, least recently used first
50
+ this.bytes = 0;
51
+ // changes whenever the sync drops anything (delete, deleteUrl, clear), so a read that started
52
+ // before is not kept (see _readValueSet). Keeping a value set or dropping one to make room
53
+ // doesn't change it: reads of other value sets under way at the same time are still kept.
54
+ this.generation = 0;
55
+ }
56
+
57
+ get size() {
58
+ return this.entries.size;
59
+ }
60
+
61
+ /** The value set for a row id, which is now the most recently used; undefined if it isn't kept */
62
+ get(id) {
63
+ const entry = this.entries.get(id);
64
+ if (!entry) {
65
+ return undefined;
66
+ }
67
+ this.entries.delete(id);
68
+ this.entries.set(id, entry);
69
+ return entry.vs;
70
+ }
71
+
72
+ /** The value set for a row id, without making it any more recently used */
73
+ peek(id) {
74
+ const entry = this.entries.get(id);
75
+ return entry ? entry.vs : undefined;
76
+ }
77
+
78
+ set(id, vs, bytes) {
79
+ this._drop(id);
80
+ if (!(bytes >= 0) || bytes > this.maxBytes) {
81
+ return;
82
+ }
83
+ this.entries.set(id, { vs, bytes });
84
+ this.bytes += bytes;
85
+ while (this.bytes > this.maxBytes) {
86
+ this._drop(this.entries.keys().next().value);
87
+ }
88
+ }
89
+
90
+ delete(id) {
91
+ this.generation++;
92
+ this._drop(id);
93
+ }
94
+
95
+ deleteUrl(url) {
96
+ this.generation++;
97
+ for (const [id, entry] of this.entries) {
98
+ if (entry.vs.url === url) {
99
+ this._drop(id);
100
+ }
101
+ }
102
+ }
103
+
104
+ clear() {
105
+ this.generation++;
106
+ this.entries.clear();
107
+ this.bytes = 0;
108
+ }
109
+
110
+ /** Removes a row without changing the generation: what the sync drops goes through delete */
111
+ _drop(id) {
112
+ const entry = this.entries.get(id);
113
+ if (entry) {
114
+ this.entries.delete(id);
115
+ this.bytes -= entry.bytes;
116
+ }
117
+ }
118
+ }
119
+
17
120
  /**
18
121
  * VSAC (Value Set Authority Center) ValueSet provider
19
122
  * Fetches and caches ValueSets from the NLM VSAC FHIR server
@@ -21,6 +124,11 @@ const VSAC_VALUESET_URL_PREFIX = 'http://cts.nlm.nih.gov/fhir/ValueSet/';
21
124
  class VSACValueSetProvider extends AbstractValueSetProvider {
22
125
  SYNC_AT_START_UP = false;
23
126
 
127
+ // A value set asked for at a version VSAC doesn't have is not found, as for value sets from
128
+ // packages (#94). It used to be answered from the latest version: set this to true to go back
129
+ // to that
130
+ static VERSION_FALLBACK_TO_LATEST = false;
131
+
24
132
  /**
25
133
  * @param {Object} config - Configuration object
26
134
  * @param {string} config.apiKey - API key for VSAC authentication
@@ -30,6 +138,13 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
30
138
  * @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
31
139
  * @param {string} [config.resyncPassword] - If set, enables the operator "resync a ValueSet"
32
140
  * form on the /info page, gated by this password. If unset, the form is not offered.
141
+ * @param {number} [config.valueSetCacheMB=64] - Size (of their stored JSON) of the recently used
142
+ * value sets kept parsed (see RecentValueSets); 0 for none. This is measured in stored JSON,
143
+ * not memory: parsed, they take several times this much heap, and in cluster mode each
144
+ * worker has its own
145
+ * @param {string} [config.cluster] - set by --role (library/role.js), else absent: 'leader'
146
+ * (the master) syncs, and bumps the database's generation after each sync; 'follower' (a
147
+ * tx-worker) never syncs, and catches up when the generation moves (catchUp)
33
148
  */
34
149
  constructor(config, stats) {
35
150
  super();
@@ -47,13 +162,25 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
47
162
  // the key has been sent in the clear)
48
163
  this.baseUrl = (config.baseUrl || 'https://cts.nlm.nih.gov/fhir').replace(/\/+$/, '');
49
164
  if (this.baseUrl.startsWith('http://')) {
50
- console.warn(`VSAC: baseUrl ${this.baseUrl} is http, so the API key is sent unencrypted - use https`);
165
+ vsacLog.warn(`baseUrl ${this.baseUrl} is http, so the API key is sent unencrypted - use https`);
51
166
  }
52
167
  this.refreshIntervalHours = config.refreshIntervalHours || 24;
168
+ this.valueSetCacheMB = config.valueSetCacheMB ?? DEFAULT_VALUESET_CACHE_MB;
53
169
 
54
170
  this.dbPath = path.join(this.cacheFolder, 'vsac-valuesets.db');
55
- this.database = new ValueSetDatabase(this.dbPath);
56
- this.valueSetMap = new Map();
171
+ this.cluster = config.cluster || null;
172
+ // a follower only reads: the master creates and migrates the file (a file not migrated
173
+ // yet fails to open, and is tried again on the next use)
174
+ this.database = new ValueSetDatabase(this.dbPath, { shared: !!this.cluster, readOnly: this.cluster === 'follower' });
175
+ // The value sets aren't held in memory: each one is read from the database when it is
176
+ // asked for (see fetchValueSet), apart from the recently used ones (see RecentValueSets).
177
+ // This is the count of urls as of the last recount
178
+ this.urlCount = 0;
179
+ // a follower: the generation it last caught up with (null: not yet - the master hasn't
180
+ // made the database), and when it last compared
181
+ this.loadedGeneration = null;
182
+ this.generationCheckedAt = 0;
183
+ this.catchingUp = null;
57
184
  this.initialized = false;
58
185
  this.refreshTimer = null;
59
186
  this.isRefreshing = false;
@@ -76,6 +203,30 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
76
203
  return "vsac";
77
204
  }
78
205
 
206
+ // the recently used value sets were read from this database: another one starts with none
207
+ get database() {
208
+ return this._database;
209
+ }
210
+
211
+ set database(database) {
212
+ this._database = database;
213
+ if (this._recent) {
214
+ this._recent.clear();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The recently used value sets (made when first needed)
220
+ * @returns {RecentValueSets}
221
+ * @private
222
+ */
223
+ _recentValueSets() {
224
+ if (!this._recent) {
225
+ this._recent = new RecentValueSets((this.valueSetCacheMB ?? DEFAULT_VALUESET_CACHE_MB) * 1024 * 1024);
226
+ }
227
+ return this._recent;
228
+ }
229
+
79
230
  sourcePackage() {
80
231
  return "vsac";
81
232
  }
@@ -88,50 +239,236 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
88
239
  if (this.initialized) {
89
240
  return;
90
241
  }
242
+ if (this.cluster === 'follower') {
243
+ // the master creates the database and syncs; this process only reads
244
+ if (await this.database.exists()) {
245
+ try {
246
+ await this._followDatabase();
247
+ } catch (error) {
248
+ // not migrated yet by the master: no value sets until a catch-up finds it done
249
+ vsacLog.info(`${error.message}; trying again on use`);
250
+ }
251
+ }
252
+ this.initialized = true;
253
+ return;
254
+ }
255
+ // initialize() is re-entered by lookups while the first call is still syncing; only
256
+ // the first call checks for (and resumes) an incomplete sync
257
+ const firstCall = !this._startupChecked;
258
+ this._startupChecked = true;
91
259
  this.stats.addTask('VSAC Sync', `${this.refreshIntervalHours} hours`);
92
260
 
93
261
  // Create database if it doesn't exist
94
262
  if (!(await this.database.exists())) {
95
263
  await this.database.create();
96
- } else {
97
- // Schema migrations are applied lazily by the database layer on first
98
- // connection. Just load existing data.
99
- await this._reloadMap();
100
264
  }
101
- if (this.SYNC_AT_START_UP || this.valueSetMap.size == 0) {
102
- await this.refreshValueSets();
265
+ // Schema migrations are applied lazily by the database layer on first
266
+ // connection. Count what is there (none, for a new database), so a
267
+ // database with no value sets is synced now rather than at the first timer
268
+ await this._recount();
269
+ const incomplete = firstCall ? await this._incompleteSyncReason() : null;
270
+ let failed = false;
271
+ let synced = false;
272
+ if (this.SYNC_AT_START_UP || this.database.vsCount == 0 || incomplete) {
273
+ if (incomplete) {
274
+ vsacLog.info(`${incomplete} - resuming the sync now`);
275
+ }
276
+ synced = true;
277
+ try {
278
+ await this.refreshValueSets();
279
+ } catch (error) {
280
+ // already recorded by refreshValueSets; the timer below retries it soon
281
+ vsacLog.error(`sync at startup failed: ${error.message}`);
282
+ failed = true;
283
+ this._failedSyncs = 1;
284
+ }
103
285
  }
104
- // Start periodic refresh
105
- this._startRefreshTimer();
286
+ // Start periodic refresh; after a failed sync, the next try comes after a short wait,
287
+ // and without a sync now, the next is due an interval after the last one (not after now)
288
+ let next;
289
+ if (failed) {
290
+ next = { delayMs: this._retryDelayMs(1), why: 'retrying the failed sync' };
291
+ } else if (synced) {
292
+ next = { delayMs: this._refreshIntervalMs(), why: 'synced at startup' };
293
+ } else {
294
+ next = await this._firstRefreshDelay();
295
+ }
296
+ vsacLog.info(`next sync due at ${new Date(Date.now() + next.delayMs).toISOString()} (${next.why})`);
297
+ this._startRefreshTimer(next.delayMs);
106
298
  this.initialized = true;
107
299
  }
108
300
 
109
301
  /**
110
- * Start the periodic refresh timer
302
+ * The wait before the first scheduled sync when initialize() didn't sync: the refresh
303
+ * interval after the last sync, as recorded in vsac_runs, rather than after this start (a
304
+ * server restarted more often than the interval would otherwise never sync). The whole
305
+ * interval when no sync is recorded, or the last one didn't work. Sets lastRefresh to the
306
+ * recorded time, so getStatistics() reports when the next sync is due
307
+ * @returns {Promise<{delayMs: number, why: string}>}
308
+ * @private
309
+ */
310
+ async _firstRefreshDelay() {
311
+ const intervalMs = this._refreshIntervalMs();
312
+ const lastRun = await this.database.getLastRun();
313
+ if (!lastRun || lastRun.status !== 'ok' || !lastRun.finished_at) {
314
+ return { delayMs: intervalMs, why: 'no completed sync recorded' };
315
+ }
316
+ // finished_at is in seconds (strftime('%s'))
317
+ const finished = lastRun.finished_at * 1000;
318
+ this.lastRefresh = new Date(finished);
319
+ return {
320
+ delayMs: Math.max(0, finished + intervalMs - Date.now()),
321
+ why: `the last sync finished at ${this.lastRefresh.toISOString()}`
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Whether the last sync didn't finish: it was cut short when the server stopped (its
327
+ * run is still marked 'running', or its work list is still saved), or it failed.
328
+ * Runs left marked 'running' are closed off as interrupted.
329
+ * @returns {Promise<string|null>} why a sync is needed now, or null if the last one completed
330
+ * @private
331
+ */
332
+ async _incompleteSyncReason() {
333
+ const lastRun = await this.database.getLastRun();
334
+ const interrupted = await this.database.markInterruptedRuns();
335
+ if (interrupted > 0) {
336
+ return 'the last sync was interrupted';
337
+ }
338
+ if ((await this.database.getSetting(VSAC_PENDING_QUEUE_KEY)) != null) {
339
+ return 'the last sync did not finish';
340
+ }
341
+ if (lastRun && lastRun.status === 'error') {
342
+ return 'the last sync failed';
343
+ }
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * The URLs an interrupted or failed run didn't get through: the in-flight URL and
349
+ * everything after it, plus first-pass failures it hadn't retried yet.
350
+ * @returns {Promise<string[]>}
351
+ * @private
352
+ */
353
+ async _loadPendingQueue() {
354
+ try {
355
+ const queue = JSON.parse(await this.database.getSetting(VSAC_PENDING_QUEUE_KEY) || '[]');
356
+ const progress = JSON.parse(await this.database.getSetting(VSAC_PENDING_PROGRESS_KEY) || '{}');
357
+ if (!Array.isArray(queue)) {
358
+ return [];
359
+ }
360
+ const requeue = Array.isArray(progress.requeue) ? progress.requeue : [];
361
+ return [...requeue.slice(progress.requeueDone || 0), ...queue.slice(progress.done || 0)];
362
+ } catch (error) {
363
+ debugLog(error);
364
+ return [];
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Save the phase-2 work list, so a restart can pick up where this run stops
370
+ * @private
371
+ */
372
+ async _savePendingQueue(queue) {
373
+ await this.database.setSetting(VSAC_PENDING_QUEUE_KEY, JSON.stringify(queue));
374
+ await this._savePendingProgress({ done: 0, requeue: [], requeueDone: 0 });
375
+ }
376
+
377
+ /**
378
+ * @param {{done: number, requeue: string[], requeueDone: number}} progress - first-pass
379
+ * URLs processed, the URLs that failed and are waiting for a retry, and retries done
380
+ * @private
381
+ */
382
+ async _savePendingProgress(progress) {
383
+ await this.database.setSetting(VSAC_PENDING_PROGRESS_KEY, JSON.stringify(progress));
384
+ }
385
+
386
+ /**
387
+ * @private
388
+ */
389
+ async _clearPendingQueue() {
390
+ await this.database.deleteSetting(VSAC_PENDING_QUEUE_KEY);
391
+ await this.database.deleteSetting(VSAC_PENDING_PROGRESS_KEY);
392
+ }
393
+
394
+ /**
395
+ * Waits before trying a failed sync again: 1, 5 and then every 30 minutes (never longer
396
+ * than the refresh interval). A failure at startup is often passing (the network is not
397
+ * up yet, or a connection attempt timed out while the server was busy loading), so it
398
+ * should not wait a whole day.
399
+ */
400
+ static RETRY_DELAYS_MS = [60 * 1000, 5 * 60 * 1000, 30 * 60 * 1000];
401
+
402
+ /**
403
+ * @param {number} failures - failed syncs in a row (1 or more)
404
+ * @returns {number} milliseconds to wait before the next try
111
405
  * @private
112
406
  */
113
- _startRefreshTimer() {
407
+ _retryDelayMs(failures) {
408
+ const delays = VSACValueSetProvider.RETRY_DELAYS_MS;
409
+ const delay = delays[Math.min(failures, delays.length) - 1];
410
+ return Math.min(delay, this._refreshIntervalMs());
411
+ }
412
+
413
+ /**
414
+ * Start the refresh timer. Each sync sets the timer for the next one, whether it worked
415
+ * or not: the refresh interval after a sync that worked, a shorter wait after one that
416
+ * failed (see RETRY_DELAYS_MS).
417
+ * @param {number} [delayMs] - wait before the next sync (default: the refresh interval)
418
+ * @private
419
+ */
420
+ _startRefreshTimer(delayMs = undefined) {
421
+ this._stopped = false;
114
422
  if (this.refreshTimer) {
115
- clearInterval(this.refreshTimer);
423
+ clearTimeout(this.refreshTimer);
116
424
  }
117
425
 
118
- const intervalMs = this.refreshIntervalHours * 60 * 60 * 1000;
119
- this.refreshTimer = setInterval(async () => {
426
+ const intervalMs = this._refreshIntervalMs();
427
+ this.refreshTimer = setTimeout(async () => {
428
+ let next = intervalMs;
120
429
  try {
121
430
  await this.refreshValueSets();
431
+ this._failedSyncs = 0;
122
432
  } catch (error) {
123
433
  debugLog(error);
124
- this.log.error(error, 'Error during scheduled refresh:');
434
+ this._failedSyncs = (this._failedSyncs || 0) + 1;
435
+ next = this._retryDelayMs(this._failedSyncs);
436
+ vsacLog.error(`scheduled sync failed (next try in ${Math.round(next / 60000)} min): ${error.message}`);
437
+ } finally {
438
+ if (!this._stopped) {
439
+ this._startRefreshTimer(next);
440
+ }
125
441
  }
126
- }, intervalMs);
442
+ }, delayMs ?? intervalMs);
443
+ }
444
+
445
+ /**
446
+ * The refresh interval in ms. Node runs a timer whose delay is above 2^31-1 ms (about 596 hours)
447
+ * or not a positive number after 1 ms instead, so the refresh would run back to back: such a
448
+ * setting is clamped (or, when it isn't a positive number, replaced by the 24 hour default).
449
+ * @private
450
+ */
451
+ _refreshIntervalMs() {
452
+ const hours = Number(this.refreshIntervalHours);
453
+ if (!Number.isFinite(hours) || hours <= 0) {
454
+ vsacLog.warn(`refreshIntervalHours ${this.refreshIntervalHours} is not a positive number; using 24`);
455
+ return 24 * 60 * 60 * 1000;
456
+ }
457
+ const ms = hours * 60 * 60 * 1000;
458
+ if (ms > MAX_TIMER_MS) {
459
+ vsacLog.warn(`refreshIntervalHours ${hours} is longer than a timer can wait; using ${Math.floor(MAX_TIMER_MS / 3600000)} hours`);
460
+ return MAX_TIMER_MS;
461
+ }
462
+ return ms;
127
463
  }
128
464
 
129
465
  /**
130
466
  * Stop the refresh timer (for cleanup)
131
467
  */
132
468
  stopRefreshTimer() {
469
+ this._stopped = true;
133
470
  if (this.refreshTimer) {
134
- clearInterval(this.refreshTimer);
471
+ clearTimeout(this.refreshTimer);
135
472
  this.refreshTimer = null;
136
473
  }
137
474
  }
@@ -143,7 +480,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
143
480
  async refreshValueSets() {
144
481
  this.stats.task('VSAC Sync', 'running');
145
482
  if (this.isRefreshing) {
146
- console.log('Refresh already in progress, skipping');
483
+ vsacLog.info('Refresh already in progress, skipping');
147
484
  return;
148
485
  }
149
486
  this.queue = [];
@@ -154,17 +491,18 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
154
491
 
155
492
  try {
156
493
  // phase 1: list all value sets
157
- console.log('Starting VSAC ValueSet refresh...');
494
+ vsacLog.info('Starting VSAC ValueSet refresh...');
158
495
 
159
496
  // This lists all the currently valid value sets by URL, but not the older versions
160
497
  let url = '/ValueSet?_offset=0&_count=1000&_elements=id,url,version,status';
498
+ const known = await this.database.listVersionKeys();
161
499
 
162
500
  let total = undefined;
163
501
  let count = 0;
164
502
  let ncount = 0;
165
503
 
166
504
  while (url) {
167
- console.log(`Sync: ${count} of ${total} - ${ncount} new`);
505
+ vsacLog.debug(`Sync: ${count} of ${total} - ${ncount} new`);
168
506
  this.stats.task('VSAC Sync', `Sync: ${count} of ${total} - ${ncount} new`);
169
507
 
170
508
  const bundle = await this._fetchBundle(url);
@@ -177,7 +515,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
177
515
  if (vs) {
178
516
  count++;
179
517
  // if we've seen this value set before, then we've got nothing new here.
180
- if (!this.valueSetMap.has(vs.url+"|"+vs.version)) {
518
+ if (!known.has(vs.url+"|"+vs.version)) {
181
519
  this.queue.push(vs.url);
182
520
  ncount++;
183
521
  }
@@ -188,22 +526,31 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
188
526
 
189
527
  // Safety check against infinite loops
190
528
  if (count > total) {
191
- console.log(`Reached total count (${total}), stopping`);
529
+ vsacLog.info(`Reached total count (${total}), stopping`);
192
530
  break;
193
531
  }
194
532
  }
195
533
 
196
534
  this.lastRefresh = new Date();
197
- console.log(`VSAC refresh phase 1 done. Total: ${count} with ${ncount} new items`);
535
+ vsacLog.info(`VSAC refresh phase 1 done. Total: ${count} with ${ncount} new items`);
198
536
  this.stats.task('VSAC Sync', `VSAC refresh phase 1 done. Total: ${count} with ${ncount} new items`);
199
537
 
200
538
  // phase 1b: query for recently updated value sets via _lastUpdated
201
539
  let lastUpdatedCount = await this._scanLastUpdated();
202
- console.log(`VSAC refresh phase 1b done. ${lastUpdatedCount} additional items from _lastUpdated`);
540
+ vsacLog.info(`VSAC refresh phase 1b done. ${lastUpdatedCount} additional items from _lastUpdated`);
203
541
  this.stats.task('VSAC Sync', `Phase 1b: ${lastUpdatedCount} from _lastUpdated`);
204
542
 
205
- // deduplicate the queue
206
- this.queue = [...new Set(this.queue)];
543
+ // Pick up what an interrupted or failed run didn't finish. Phase 1 only queues a
544
+ // URL whose current version is missing, so a URL that was part way through its
545
+ // versions would otherwise be missed. The leftovers go first, and the deduplicated
546
+ // queue is saved so that a restart can carry on from here.
547
+ const leftover = await this._loadPendingQueue();
548
+ if (leftover.length > 0) {
549
+ vsacLog.info(`resuming ${leftover.length} value set(s) left over from the last sync`);
550
+ this.stats.task('VSAC Sync', `Resuming ${leftover.length} left over from the last sync`);
551
+ }
552
+ this.queue = [...new Set([...leftover, ...this.queue])];
553
+ await this._savePendingQueue(this.queue);
207
554
 
208
555
  let tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount : 0 };
209
556
  // URLs that fail even after a requeue are permanently dropped this run; we
@@ -211,6 +558,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
211
558
  let permanentFailures = 0;
212
559
  // phase 2: query for history & content
213
560
  this.requeue = [];
561
+ const progress = { done: 0, requeue: this.requeue, requeueDone: 0 };
214
562
  for (let q of this.queue) {
215
563
  this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
216
564
  try {
@@ -221,8 +569,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
221
569
  this.stats.task('VSAC Sync', error.message);
222
570
  }
223
571
  tracking.count++;
572
+ progress.done++;
573
+ await this._savePendingProgress(progress);
224
574
  }
225
- console.log("Requeue");
575
+ vsacLog.debug("Requeue");
226
576
  for (let q of this.requeue) {
227
577
  this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
228
578
  try {
@@ -233,10 +583,16 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
233
583
  this.stats.task('VSAC Sync', error.message);
234
584
  }
235
585
  tracking.count++;
586
+ progress.requeueDone++;
587
+ await this._savePendingProgress(progress);
236
588
  }
237
589
 
238
- // Reload map with fresh data
239
- await this._reloadMap();
590
+ // the followers catch up when they see the generation move
591
+ if (this.cluster) {
592
+ await this.database.bumpGeneration();
593
+ }
594
+ // Recount, with the fresh data
595
+ await this._recount();
240
596
 
241
597
  // Commit the _lastUpdated watermark only now that phase 2 has durably
242
598
  // written everything. Hold it back if any URL was permanently dropped, so
@@ -247,22 +603,30 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
247
603
  await this.database.setSetting(VSAC_LAST_UPDATED_KEY, this._pendingLastUpdated);
248
604
  } else if (permanentFailures > 0) {
249
605
  const holdMsg = `Holding _lastUpdated watermark: ${permanentFailures} URL(s) failed after retry; will re-scan next run`;
250
- console.log(holdMsg);
606
+ vsacLog.info(holdMsg);
251
607
  this.stats.task('VSAC Sync', holdMsg);
252
608
  }
609
+ // the work list is done (URLs that failed twice are re-found through the held watermark)
610
+ await this._clearPendingQueue();
253
611
 
254
612
  let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, New: ${tracking.totalNew}, Updated: ${tracking.totalUpdated}`;
255
613
  this.stats.taskDone('VSAC Sync', msg);
256
- console.log(msg);
614
+ vsacLog.info(msg);
257
615
 
258
616
  await this.database.finishRun(runId, tracking.totalFetched, tracking.totalNew, tracking.totalUpdated);
259
617
  } catch (error) {
260
618
  debugLog(error, 'Error during VSAC refresh:');
261
619
  this.stats.taskError('VSAC Sync', `Error (${error.message})`);
262
620
  await this.database.failRun(runId, error.message);
621
+ if (this.cluster) {
622
+ // what the run wrote before it failed is in the database
623
+ await this.database.bumpGeneration();
624
+ }
263
625
  throw error;
264
626
  } finally {
265
627
  this.isRefreshing = false;
628
+ // each row written was dropped as it was written; start again anyway after a full sync
629
+ this._recentValueSets().clear();
266
630
  }
267
631
  }
268
632
 
@@ -295,38 +659,40 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
295
659
 
296
660
  // Process sequentially to avoid database locking
297
661
  for (const valueSet of valueSets) {
298
- const key = valueSet.url+"|"+valueSet.version;
299
- const existing = this.valueSetMap.get(key);
662
+ const existing = valueSet.version ? await this.database.getVersion(valueSet.url, valueSet.version) : undefined;
300
663
  const newHash = this._hashValueSet(valueSet);
301
664
 
302
665
  if (existing) {
303
666
  // We've seen this url|version before. Decide whether the content
304
667
  // has actually changed by comparing hashes.
305
668
  //
306
- // Note: _reloadMap() mutates the in-memory jsonObj (strips inc.version
307
- // from compose.include/exclude), so we cannot reliably recompute a
308
- // hash from existing.jsonObj — it would not match the hash of the
309
- // original unmutated JSON we stored. For rows predating this feature
669
+ // Note: the value sets served are altered after they are read (_prepare
670
+ // strips inc.version from compose.include/exclude), so the hash is
671
+ // the one stored with the original unmutated JSON. For rows predating this feature
310
672
  // (content_hash NULL), we defer update detection until the next cycle:
311
673
  // the upsert below runs only when hashes differ, so on the *next*
312
674
  // sync after migration we'll have a proper baseline.
313
- if (existing.contentHash && existing.contentHash === newHash) {
675
+ if (existing.content_hash && existing.content_hash === newHash) {
314
676
  // No change - just touch last_seen
315
677
  await this.database.seeValueSet(valueSet);
316
- } else if (!existing.contentHash) {
678
+ } else if (!existing.content_hash) {
317
679
  // Legacy row without a stored hash - backfill the hash silently
318
680
  // without emitting a spurious 'updated' event. We do a lightweight
319
681
  // touch + hash update rather than a full upsert+event.
320
682
  await this.database.seeValueSet(valueSet);
321
683
  await this.database.setContentHash(valueSet.id, newHash);
684
+ this._recentValueSets().delete(existing.id);
322
685
  } else {
323
686
  // Content has changed - treat as update
324
687
  await this.database.upsertValueSet(valueSet, newHash);
688
+ this._recentValueSets().delete(existing.id);
689
+ this._recentValueSets().delete(valueSet.id);
325
690
  await this.database.recordEvent('updated', valueSet.url, valueSet.version);
326
691
  updatedCount++;
327
692
  }
328
693
  } else {
329
694
  await this.database.upsertValueSet(valueSet, newHash);
695
+ this._recentValueSets().delete(valueSet.id);
330
696
  await this.database.recordEvent('new', valueSet.url, valueSet.version);
331
697
  newCount++;
332
698
  }
@@ -411,43 +777,139 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
411
777
  base = new URL(this.baseUrl);
412
778
  next = new URL(nextLink.url, this.baseUrl + '/');
413
779
  } catch (e) {
414
- console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: ${e.message}`);
780
+ vsacLog.warn(`ignoring next link ${String(nextLink.url).substring(0, 200)}: ${e.message}`);
415
781
  return null;
416
782
  }
417
783
  const basePath = base.pathname.replace(/\/+$/, '');
418
784
  if (next.host !== base.host || !(next.pathname === basePath || next.pathname.startsWith(basePath + '/'))) {
419
- console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: it is not on ${this.baseUrl}`);
785
+ vsacLog.warn(`ignoring next link ${String(nextLink.url).substring(0, 200)}: it is not on ${this.baseUrl}`);
420
786
  return null;
421
787
  }
422
788
  return next.pathname.substring(basePath.length) + next.search;
423
789
  }
424
790
 
425
791
  /**
426
- * Reload the in-memory map from database (thread-safe)
792
+ * Refresh the counts from the database (the value sets themselves are read on demand)
427
793
  * @returns {Promise<void>}
428
794
  * @private
429
795
  */
430
- async _reloadMap() {
431
- const newMap = await this.database.loadAllValueSets(this.sourcePackage());
432
- for (const vs of newMap.values()) {
433
- if (vs.jsonObj.compose) {
434
- for (const inc of vs.jsonObj.compose.include || []) {
435
- if (inc.version) {
436
- delete inc.version;
437
- }
438
- }for (const inc of vs.jsonObj.compose.exclude || []) {
439
- if (inc.version) {
440
- delete inc.version;
441
- }
796
+ async _recount() {
797
+ const counts = await this.database.countValueSets();
798
+ this.urlCount = counts.urls;
799
+ }
800
+
801
+ /**
802
+ * Read a value set from the database and make it ready to serve: the compose doesn't pin
803
+ * code system versions, and the id is in this provider's id space
804
+ * @param {string} id - the value set's own id
805
+ * @returns {Promise<ValueSet|null>}
806
+ * @private
807
+ */
808
+ async _loadValueSet(id) {
809
+ const vs = await this.database.loadValueSet(id, this.sourcePackage());
810
+ if (vs) {
811
+ this._prepare(vs);
812
+ }
813
+ return vs;
814
+ }
815
+
816
+ /**
817
+ * A value set by row id, from the recently used ones if it is there, or else read (and kept)
818
+ * @param {string} id - the value set's own id
819
+ * @returns {Promise<ValueSet|null>} shared with other requests: not to be changed
820
+ * @private
821
+ */
822
+ async _readValueSet(id) {
823
+ const recent = this._recentValueSets();
824
+ let vs = recent.get(id);
825
+ if (vs) {
826
+ return vs;
827
+ }
828
+ // if the sync drops anything while this is read, what is read may be older than the database
829
+ const generation = recent.generation;
830
+ vs = await this._loadValueSet(id);
831
+ if (vs && recent.generation === generation) {
832
+ recent.set(id, vs, vs.storedLength);
833
+ }
834
+ return vs;
835
+ }
836
+
837
+ _prepare(vs) {
838
+ if (vs.jsonObj.compose) {
839
+ for (const inc of vs.jsonObj.compose.include || []) {
840
+ if (inc.version) {
841
+ delete inc.version;
842
+ }
843
+ }
844
+ for (const inc of vs.jsonObj.compose.exclude || []) {
845
+ if (inc.version) {
846
+ delete inc.version;
442
847
  }
443
848
  }
444
849
  }
445
850
  // the value sets are served in this provider's id space
446
- for (const vs of new Set(newMap.values())) {
447
- applyIdPrefix(vs, this.spaceId);
851
+ applyIdPrefix(vs, this.spaceId);
852
+ }
853
+
854
+ /**
855
+ * A follower (cluster.md): bring what this process holds up to date with the database, and
856
+ * remember the generation it is now at. Only the recently used value sets are held, so they
857
+ * are dropped (they may be older than the database now), and the urls recounted. The
858
+ * generation is read first: a sync that finishes meanwhile makes the next check catch up
859
+ * again, never the reverse.
860
+ * @returns {Promise<void>}
861
+ * @private
862
+ */
863
+ async _followDatabase() {
864
+ const generation = await this.database.getGeneration();
865
+ this._recentValueSets().clear();
866
+ await this._recount();
867
+ this.loadedGeneration = generation;
868
+ }
869
+
870
+ /**
871
+ * A follower: pick up what the master's sync wrote. At most every GENERATION_CHECK_MS,
872
+ * read the database's generation; if it isn't the one this process last caught up with,
873
+ * catch up (_followDatabase). Runs in the background (requests meanwhile use what they
874
+ * have) and never rejects. Returns the promise of the check, for tests.
875
+ * @returns {Promise<void>}
876
+ */
877
+ catchUp() {
878
+ const now = Date.now();
879
+ if (this.catchingUp || now - this.generationCheckedAt < GENERATION_CHECK_MS) {
880
+ return this.catchingUp || Promise.resolve();
448
881
  }
449
- // Atomic replacement of the map
450
- this.valueSetMap = newMap;
882
+ this.generationCheckedAt = now;
883
+ this.catchingUp = (async () => {
884
+ // not before the master has made it: opening it would create an empty file
885
+ if (this.loadedGeneration === null && !(await this.database.exists())) {
886
+ return;
887
+ }
888
+ const generation = await this.database.getGeneration();
889
+ if (generation !== this.loadedGeneration) {
890
+ vsacLog.info(`the database is at generation ${generation}, this process at ${this.loadedGeneration}; catching up`);
891
+ await this._followDatabase();
892
+ }
893
+ })().catch((error) => {
894
+ debugLog(error, 'VSAC: catching up with the database failed');
895
+ }).finally(() => {
896
+ this.catchingUp = null;
897
+ });
898
+ return this.catchingUp;
899
+ }
900
+
901
+ /**
902
+ * A follower whose master hasn't made the database yet has no value sets; reading would
903
+ * open the file, and so create an empty one. Starts a catch-up check either way.
904
+ * @returns {boolean} true if there is nothing to read yet
905
+ * @private
906
+ */
907
+ _followerHasNoDatabase() {
908
+ if (this.cluster !== 'follower') {
909
+ return false;
910
+ }
911
+ void this.catchUp();
912
+ return this.loadedGeneration === null;
451
913
  }
452
914
 
453
915
  /**
@@ -459,11 +921,37 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
459
921
  async fetchValueSet(url, version) {
460
922
  await this.initialize();
461
923
  this._validateFetchParams(url, version);
924
+ if (this._followerHasNoDatabase()) {
925
+ return null;
926
+ }
927
+
928
+ // which row the url (and version) refers to is worked out the way it always was, from a
929
+ // map keyed like the one loadAllValueSets builds - but of just this url's ids and versions
930
+ let rows = await this.database.listVersions(url);
931
+ while (rows.length > 0) {
932
+ const versions = new Map();
933
+ for (const row of rows) {
934
+ this.database.addToMap(versions, row.id, row.url, row.version, row);
935
+ }
936
+ const row = this._findVersion(versions, url, version);
937
+ if (!row) {
938
+ return null;
939
+ }
940
+ const vs = await this._readValueSet(row.id);
941
+ if (vs) {
942
+ return await this.checkFullVS(vs);
943
+ }
944
+ // it was rejected (and logged): as when it couldn't be loaded into the map, it's as if it isn't there
945
+ rows = rows.filter(r => r !== row);
946
+ }
947
+ return null;
948
+ }
462
949
 
950
+ _findVersion(versions, url, version) {
463
951
  // Try exact match first: url|version
464
952
  let key = `${url}|${version}`;
465
- if (this.valueSetMap.has(key)) {
466
- return await this.checkFullVS(this.valueSetMap.get(key));
953
+ if (versions.has(key)) {
954
+ return versions.get(key);
467
955
  }
468
956
 
469
957
  // If version is semver, try url|major.minor
@@ -472,8 +960,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
472
960
  const majorMinor = VersionUtilities.getMajMin(version);
473
961
  if (majorMinor) {
474
962
  key = `${url}|${majorMinor}`;
475
- if (this.valueSetMap.has(key)) {
476
- return await this.checkFullVS(this.valueSetMap.get(key));
963
+ if (versions.has(key)) {
964
+ return versions.get(key);
477
965
  }
478
966
  }
479
967
  }
@@ -481,18 +969,20 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
481
969
  // Ignore version parsing errors
482
970
  }
483
971
 
484
- // Finally try just the URL
485
- if (this.valueSetMap.has(url)) {
486
- return await this.checkFullVS(this.valueSetMap.get(url));
972
+ // Finally try just the URL - only when no version was asked for (see VERSION_FALLBACK_TO_LATEST)
973
+ if ((!version || VSACValueSetProvider.VERSION_FALLBACK_TO_LATEST) && versions.has(url)) {
974
+ return versions.get(url);
487
975
  }
488
976
 
489
977
  return null;
490
978
  }
491
979
 
492
980
  async fetchValueSetById(id) {
493
- // the map is keyed by the value set's own id (and its url); the value set itself
494
- // carries the prefixed id, which is how a url key that happens to match is excluded
495
- const vs = this.valueSetMap.get(stripIdPrefix(id, this.spaceId));
981
+ if (this._followerHasNoDatabase()) {
982
+ return null;
983
+ }
984
+ const ownId = stripIdPrefix(id, this.spaceId);
985
+ const vs = ownId ? await this._readValueSet(ownId) : null;
496
986
  return vs && vs.id === id ? await this.checkFullVS(vs) : null;
497
987
  }
498
988
  /**
@@ -503,12 +993,39 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
503
993
  async searchValueSets(searchParams, elements) {
504
994
  await this.initialize();
505
995
  this._validateSearchParams(searchParams);
996
+ if (this._followerHasNoDatabase()) {
997
+ return [];
998
+ }
506
999
 
507
1000
  if (searchParams.length === 0) {
508
1001
  return [];
509
1002
  }
510
1003
 
511
- return await this.database.search(this.spaceId, this.valueSetMap, searchParams, elements);
1004
+ if (elements && elements.length > 0 && elements.every(e => ValueSetDatabase.INDEXED_COLUMNS.includes(e))) {
1005
+ // built from the indexed columns, without reading the value sets
1006
+ return await this.database.search(this.spaceId, null, searchParams, elements);
1007
+ }
1008
+ // The matches carry what a search is sorted by; each value set is only read if it is on the
1009
+ // page that is returned (see SearchWorker.loadPage)
1010
+ return (await this.database.searchRows(searchParams)).map(row => this._searchMatch(row));
1011
+ }
1012
+
1013
+ _searchMatch(row) {
1014
+ const match = { resourceType: 'ValueSet', id: this.spaceId ? `${this.spaceId}-${row.id}` : row.id };
1015
+ for (const name of ['url', 'version', 'date', 'name']) {
1016
+ if (row[name] != null) {
1017
+ match[name] = row[name];
1018
+ }
1019
+ }
1020
+ // the value set as search would have returned it; null if it is rejected when it is read.
1021
+ // A search can match thousands, so what it reads is not kept, and doesn't push out what is
1022
+ Object.defineProperty(match, 'load', {
1023
+ value: async () => {
1024
+ const vs = this._recentValueSets().peek(row.id) || await this._loadValueSet(row.id);
1025
+ return vs ? vs.jsonObj : null;
1026
+ }
1027
+ });
1028
+ return match;
512
1029
  }
513
1030
 
514
1031
  /**
@@ -527,24 +1044,18 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
527
1044
  isRefreshing: this.isRefreshing,
528
1045
  refreshIntervalHours: this.refreshIntervalHours,
529
1046
  nextRefresh: this.refreshTimer && this.lastRefresh
530
- ? new Date(this.lastRefresh.getTime() + (this.refreshIntervalHours * 60 * 60 * 1000))
1047
+ ? new Date(this.lastRefresh.getTime() + this._refreshIntervalMs())
531
1048
  : null
532
1049
  }
533
1050
  };
534
1051
  }
535
1052
 
536
1053
  /**
537
- * Get the number of value sets loaded into memory
538
- * @returns {number} Number of unique value sets in map
1054
+ * Get the number of value sets available (as of the last refresh)
1055
+ * @returns {number} Number of unique value set urls
539
1056
  */
540
1057
  getMapSize() {
541
- const uniqueUrls = new Set();
542
- for (const [key, valueSet] of this.valueSetMap.entries()) {
543
- if (!key.includes('|')) { // Only count base URL keys
544
- uniqueUrls.add(valueSet.url);
545
- }
546
- }
547
- return uniqueUrls.size;
1058
+ return this.urlCount;
548
1059
  }
549
1060
 
550
1061
  /**
@@ -576,22 +1087,54 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
576
1087
  }
577
1088
 
578
1089
  async listAllValueSets() {
1090
+ if (this._followerHasNoDatabase()) {
1091
+ return [];
1092
+ }
579
1093
  return await this.database.listAllValueSets();
580
1094
  }
581
1095
 
1096
+ async scanAllValueSets(fn) {
1097
+ if (this._followerHasNoDatabase()) {
1098
+ return;
1099
+ }
1100
+ // the latest version of each url, once (read in batches)
1101
+ const versions = new Map();
1102
+ for (const row of await this.database.listVersions(null)) {
1103
+ let map = versions.get(row.url);
1104
+ if (!map) {
1105
+ map = new Map();
1106
+ versions.set(row.url, map);
1107
+ }
1108
+ this.database.addToMap(map, row.id, row.url, row.version, row);
1109
+ }
1110
+ const urls = [...versions.keys()].sort();
1111
+ for (let i = 0; i < urls.length; i += 500) {
1112
+ const batch = urls.slice(i, i + 500).map(url => ({ url, row: this._findVersion(versions.get(url), url) }));
1113
+ const valueSets = await this.database.loadValueSets(batch.map(b => b.row.id), this.sourcePackage());
1114
+ for (const b of batch) {
1115
+ let vs = valueSets.get(b.row.id);
1116
+ if (vs) {
1117
+ this._prepare(vs);
1118
+ } else {
1119
+ vs = await this.fetchValueSet(b.url); // the latest was rejected
1120
+ }
1121
+ if (vs) {
1122
+ await fn(vs);
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
582
1128
  async close() {
1129
+ this._recentValueSets().clear();
583
1130
  await this.database.close();
584
1131
  }
585
1132
 
586
1133
  prefixIds() {
587
- this.#applyIdPrefix();
588
- }
589
-
590
- // the map is replaced every time it's reloaded from the database (see _reloadMap), and that
591
- // can happen before or after the spaceId is assigned - so both prefix whatever is there
592
- #applyIdPrefix() {
593
- for (const vs of new Set(this.valueSetMap.values())) {
594
- applyIdPrefix(vs, this.spaceId);
1134
+ // nothing is held in memory: each value set gets its prefix as it is read (see _prepare).
1135
+ // Anything read before the spaceId was assigned has the wrong id, so start again
1136
+ if (this._recent) {
1137
+ this._recent.clear();
595
1138
  }
596
1139
  }
597
1140
 
@@ -606,10 +1149,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
606
1149
  // if (vs.jsonObj?.compose) {
607
1150
  // return vs;
608
1151
  // }
609
- // console.log('get a full copy for the ValueSet '+vs.url+'|'+vs.version);
610
1152
  // let vsNew = await this._fetchValueSet(vs.id);
611
1153
  // await this.database.upsertValueSet(vsNew);
612
- // this.database.addToMap(this.valueSetMap, vsNew.id, vsNew.url, vsNew.version, vsNew);
613
1154
  // return new ValueSet(vsNew);
614
1155
  return vs;
615
1156
  }
@@ -634,7 +1175,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
634
1175
  }
635
1176
  }
636
1177
  let logMsg = `VSAC (${tracking.count} of ${length}) ${q}: ${vcount} versions (${perRun.newCount} new, ${perRun.updatedCount} updated)`;
637
- console.log(logMsg);
1178
+ vsacLog.debug(logMsg);
638
1179
  this.stats.task('VSAC Sync', logMsg);
639
1180
  }
640
1181
 
@@ -659,7 +1200,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
659
1200
  let serverDate = null;
660
1201
 
661
1202
  while (url) {
662
- console.log(`_lastUpdated scan: ${count} found so far`);
1203
+ vsacLog.debug(`_lastUpdated scan: ${count} found so far`);
663
1204
  this.stats.task('VSAC Sync', `_lastUpdated scan: ${count} found`);
664
1205
 
665
1206
  const bundle = await this._fetchBundle(url);
@@ -703,7 +1244,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
703
1244
  * Whether the on-demand resync form is enabled (a resyncPassword is configured).
704
1245
  */
705
1246
  _resyncEnabled() {
706
- return !!this.resyncPassword;
1247
+ // a follower doesn't write: the info pages are routed to the master
1248
+ return !!this.resyncPassword && this.cluster !== 'follower';
707
1249
  }
708
1250
 
709
1251
  /**
@@ -724,14 +1266,19 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
724
1266
 
725
1267
  /**
726
1268
  * Immediately resync a single ValueSet URL (all versions): fetch from VSAC,
727
- * upsert, and reload the in-memory map.
1269
+ * upsert, and recount.
728
1270
  * @param {string} url - the ValueSet canonical url
729
1271
  * @returns {Promise<number>} number of versions fetched
730
1272
  */
731
1273
  async resyncValueSet(url) {
732
1274
  const tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount: 0 };
733
1275
  await this.processContentAndHistory(url, tracking, 1);
734
- await this._reloadMap();
1276
+ if (this.cluster) {
1277
+ await this.database.bumpGeneration();
1278
+ }
1279
+ // the rows written were dropped as they were written; an operator asking expects none kept
1280
+ this._recentValueSets().deleteUrl(url);
1281
+ await this._recount();
735
1282
  return tracking.totalFetched;
736
1283
  }
737
1284
 
@@ -762,7 +1309,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
762
1309
  }
763
1310
  try {
764
1311
  const n = await this.resyncValueSet(url);
765
- console.log(`Manual resync of ${url}: ${n} version(s)`);
1312
+ vsacLog.info(`Manual resync of ${url}: ${n} version(s)`);
766
1313
  this.stats.task('VSAC Sync', `Manual resync of ${url}: ${n} version(s)`);
767
1314
  return `<p style="color:#070"><strong>Resynced ${escape(url)}: ${n} version(s).</strong></p>`;
768
1315
  } catch (error) {
@@ -899,10 +1446,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
899
1446
  colour = 'green';
900
1447
  } else if (row.status === 'error') {
901
1448
  detail = `Failed: ${escape(row.error_message || '')} (${duration})`;
902
- colour = 'red';
1449
+ colour = 'maroon';
903
1450
  } else {
904
1451
  detail = `Running... (started ${fmtFull(row.ts)})`;
905
- colour = 'orange';
1452
+ colour = '#a16600';
906
1453
  }
907
1454
  html += `<tr style="background:#f0f0f0">`;
908
1455
  html += `<td>${escape(timeOnly(row.ts))}</td>`;
@@ -952,5 +1499,6 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
952
1499
 
953
1500
  // Usage examples:
954
1501
  module.exports = {
955
- VSACValueSetProvider
1502
+ VSACValueSetProvider,
1503
+ vsacLog
956
1504
  };