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/xig/xig.js CHANGED
@@ -16,6 +16,7 @@ const zlib = require('zlib');
16
16
  const htmlServer = require('../library/html-server');
17
17
  const folders = require('../library/folder-setup');
18
18
  const escape = require('escape-html');
19
+ const { guarded } = require('../library/sqlite-utilities');
19
20
 
20
21
  const Logger = require('../library/logger');
21
22
  const {describeCron} = require("../library/cron-utilities");
@@ -55,6 +56,9 @@ let cacheLoadInProgress = false;
55
56
 
56
57
  // Update history - tracks every download attempt for diagnostics
57
58
  const MAX_UPDATE_HISTORY = 20;
59
+ // The daily download runs at 02:00 local time
60
+ const UPDATE_CRON = '0 2 * * *';
61
+ const UPDATE_HOUR = 2;
58
62
  let updateHistory = [];
59
63
  let updateInProgress = false;
60
64
 
@@ -99,124 +103,112 @@ function validateExternalUrl(url) {
99
103
  }
100
104
  }
101
105
 
102
- // Secure SQL query building with parameterized queries
103
- function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
104
- const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
106
+ // The columns that say which FHIR versions a resource is for (also the values of ver)
107
+ const VERSION_COLUMNS = ['R2', 'R2B', 'R3', 'R4', 'R4B', 'R5', 'R6'];
105
108
 
106
- let baseQuery = `
107
- SELECT
108
- ResourceKey, ResourceType, Type, Kind, Description, PackageKey,
109
- Realm, Authority, R2, R2B, R3, R4, R4B, R5, R6,
110
- Id, Url, Version, Status, Date, Name, Title, Content,
111
- Supplements, Details, FMM, WG, StandardsStatus, Web,
112
- (SELECT COUNT(*) FROM DependencyList WHERE TargetKey = Resources.ResourceKey) AS UsageCount
113
- FROM Resources
114
- WHERE 1=1
115
- `;
109
+ /**
110
+ * One request parameter as a string. Express makes a repeated parameter (?realm=a&realm=b) an
111
+ * array, and can make a bracketed one (?realm[x]=a) an object: the first value is used, and
112
+ * anything else is treated as not given.
113
+ */
114
+ function queryValue(v) {
115
+ if (Array.isArray(v)) {
116
+ v = v[0];
117
+ }
118
+ return typeof v === 'string' ? v : '';
119
+ }
120
+
121
+ /**
122
+ * The WHERE clause for the resource list filters (realm, auth, ver, type, rt, text, pkg,
123
+ * onlyUsed), with a ? for every value that comes from the request, and those values in order.
124
+ * Nothing from the request is put in the SQL itself: only fixed SQL chosen by it (ver, type).
125
+ * The list, its count and the summary statistics all use this, so they always agree.
126
+ *
127
+ * @param {Object} queryParams
128
+ * @returns {{where: string, params: Array}} where starts with WHERE (WHERE 1=1 when nothing is filtered)
129
+ */
130
+ function buildResourceFilter(queryParams) {
131
+ const realm = queryValue(queryParams.realm);
132
+ const auth = queryValue(queryParams.auth);
133
+ const ver = queryValue(queryParams.ver);
134
+ const type = queryValue(queryParams.type);
135
+ const rt = queryValue(queryParams.rt);
136
+ const text = queryValue(queryParams.text);
137
+ const pkg = queryValue(queryParams.pkg);
138
+ const onlyUsed = queryValue(queryParams.onlyUsed);
116
139
 
117
140
  const conditions = [];
118
141
  const params = [];
119
142
 
120
- // Realm filter
121
- if (realm && realm !== '') {
143
+ if (realm) {
122
144
  conditions.push('AND realm = ?');
123
145
  params.push(realm);
124
146
  }
125
147
 
126
- // Authority filter
127
- if (auth && auth !== '') {
148
+ if (auth) {
128
149
  conditions.push('AND authority = ?');
129
150
  params.push(auth);
130
151
  }
131
152
 
132
- // Version filter
133
- if (ver) {
134
- switch (ver) {
135
- case 'R2':
136
- conditions.push('AND R2 = 1');
137
- break;
138
- case 'R2B':
139
- conditions.push('AND R2B = 1');
140
- break;
141
- case 'R3':
142
- conditions.push('AND R3 = 1');
143
- break;
144
- case 'R4':
145
- conditions.push('AND R4 = 1');
146
- break;
147
- case 'R4B':
148
- conditions.push('AND R4B = 1');
149
- break;
150
- case 'R5':
151
- conditions.push('AND R5 = 1');
152
- break;
153
- case 'R6':
154
- conditions.push('AND R6 = 1');
155
- break;
156
- }
153
+ // a column name, so only one of the known ones
154
+ if (VERSION_COLUMNS.includes(ver)) {
155
+ conditions.push(`AND ${ver} = 1`);
157
156
  }
158
157
 
159
- // Type-specific filters
160
158
  switch (type) {
161
159
  case 'cs': // CodeSystem
162
160
  conditions.push("AND ResourceType = 'CodeSystem'");
163
161
  break;
164
-
165
162
  case 'rp': // Resource Profiles
166
163
  conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'resource'");
167
- if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
164
+ if (rt && hasCachedValue('profileResources', rt)) {
168
165
  conditions.push('AND Type = ?');
169
166
  params.push(rt);
170
167
  }
171
168
  break;
172
-
173
169
  case 'dp': // Datatype Profiles
174
170
  conditions.push("AND ResourceType = 'StructureDefinition' AND (kind = 'complex-type' OR kind = 'primitive-type')");
175
- if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
171
+ if (rt && hasCachedValue('profileTypes', rt)) {
176
172
  conditions.push('AND Type = ?');
177
173
  params.push(rt);
178
174
  }
179
175
  break;
180
-
181
176
  case 'lm': // Logical Models
182
177
  conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'logical'");
183
178
  break;
184
-
185
179
  case 'ext': // Extensions
186
180
  conditions.push("AND ResourceType = 'StructureDefinition' AND Type = 'Extension'");
187
- if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
181
+ if (rt && hasCachedValue('extensionContexts', rt)) {
188
182
  conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 2 AND Code = ?)');
189
183
  params.push(rt);
190
184
  }
191
185
  break;
192
-
193
186
  case 'vs': // ValueSets
194
187
  conditions.push("AND ResourceType = 'ValueSet'");
195
- if (rt && rt !== '' && hasTerminologySource(rt)) {
188
+ if (rt && hasTerminologySource(rt)) {
196
189
  conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
197
190
  params.push(rt);
198
191
  }
199
192
  break;
200
-
201
193
  case 'cm': // ConceptMaps
202
194
  conditions.push("AND ResourceType = 'ConceptMap'");
203
- if (rt && rt !== '' && hasTerminologySource(rt)) {
195
+ if (rt && hasTerminologySource(rt)) {
204
196
  conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
205
197
  params.push(rt);
206
198
  }
207
199
  break;
208
-
209
200
  default:
210
201
  // No specific type selected
211
- if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
202
+ if (rt && hasCachedValue('resourceTypes', rt)) {
212
203
  conditions.push('AND ResourceType = ?');
213
204
  params.push(rt);
214
205
  }
215
206
  break;
216
207
  }
217
208
 
218
- // Text search filter
219
- if (text && text !== '') {
209
+ // Text search: one FTS phrase, so the text is searched for as written (a " in it is doubled,
210
+ // which is how a phrase holds one, so it can't end the phrase and add query syntax)
211
+ if (text) {
220
212
  const ftsText = text.replace(/"/g, '""');
221
213
  if (type === 'cs') {
222
214
  conditions.push(`AND (ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)
@@ -228,129 +220,37 @@ function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
228
220
  }
229
221
  }
230
222
 
231
- // Package filter - matches packageId#version containing the search string
232
- if (pkg && pkg !== '') {
233
- conditions.push('AND PackageKey IN (SELECT PackageKey FROM Packages WHERE (Id || \'#\' || Version) LIKE ?)');
234
- params.push(`%${pkg}%`);
223
+ // Package filter - packageId#version containing the text, as written (% and _ are not wildcards)
224
+ if (pkg) {
225
+ conditions.push("AND PackageKey IN (SELECT PackageKey FROM Packages WHERE (Id || '#' || Version) LIKE ? ESCAPE '\\')");
226
+ params.push(`%${pkg.replace(/[\\%_]/g, c => '\\' + c)}%`);
235
227
  }
236
228
 
237
- // Only used filter
238
229
  if (onlyUsed === 'true') {
239
230
  conditions.push('AND EXISTS (SELECT 1 FROM DependencyList WHERE TargetKey = Resources.ResourceKey)');
240
231
  }
241
232
 
242
- // Build final query
243
- const fullQuery = baseQuery + ' ' + conditions.join(' ') + ' ORDER BY ResourceType, Type, Description LIMIT ? OFFSET ?';
244
- params.push(limit, offset);
233
+ return { where: ['WHERE 1=1', ...conditions].join(' '), params };
234
+ }
245
235
 
246
- return { query: fullQuery, params };
236
+ function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
237
+ const { where, params } = buildResourceFilter(queryParams);
238
+ const query = `
239
+ SELECT
240
+ ResourceKey, ResourceType, Type, Kind, Description, PackageKey,
241
+ Realm, Authority, R2, R2B, R3, R4, R4B, R5, R6,
242
+ Id, Url, Version, Status, Date, Name, Title, Content,
243
+ Supplements, Details, FMM, WG, StandardsStatus, Web,
244
+ (SELECT COUNT(*) FROM DependencyList WHERE TargetKey = Resources.ResourceKey) AS UsageCount
245
+ FROM Resources
246
+ ${where}
247
+ ORDER BY ResourceType, Type, Description LIMIT ? OFFSET ?`;
248
+ return { query, params: [...params, limit, offset] };
247
249
  }
248
250
 
249
251
  function buildSecureResourceCountQuery(queryParams) {
250
- const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
251
-
252
- let baseQuery = 'SELECT COUNT(*) as total FROM Resources WHERE 1=1';
253
- const conditions = [];
254
- const params = [];
255
-
256
- // Same conditions as main query but for counting
257
- if (realm && realm !== '') {
258
- conditions.push('AND realm = ?');
259
- params.push(realm);
260
- }
261
-
262
- if (auth && auth !== '') {
263
- conditions.push('AND authority = ?');
264
- params.push(auth);
265
- }
266
-
267
- if (ver) {
268
- switch (ver) {
269
- case 'R2': conditions.push('AND R2 = 1'); break;
270
- case 'R2B': conditions.push('AND R2B = 1'); break;
271
- case 'R3': conditions.push('AND R3 = 1'); break;
272
- case 'R4': conditions.push('AND R4 = 1'); break;
273
- case 'R4B': conditions.push('AND R4B = 1'); break;
274
- case 'R5': conditions.push('AND R5 = 1'); break;
275
- case 'R6': conditions.push('AND R6 = 1'); break;
276
- }
277
- }
278
-
279
- switch (type) {
280
- case 'cs':
281
- conditions.push("AND ResourceType = 'CodeSystem'");
282
- break;
283
- case 'rp':
284
- conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'resource'");
285
- if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
286
- conditions.push('AND Type = ?');
287
- params.push(rt);
288
- }
289
- break;
290
- case 'dp':
291
- conditions.push("AND ResourceType = 'StructureDefinition' AND (kind = 'complex-type' OR kind = 'primitive-type')");
292
- if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
293
- conditions.push('AND Type = ?');
294
- params.push(rt);
295
- }
296
- break;
297
- case 'lm':
298
- conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'logical'");
299
- break;
300
- case 'ext':
301
- conditions.push("AND ResourceType = 'StructureDefinition' AND Type = 'Extension'");
302
- if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
303
- conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 2 AND Code = ?)');
304
- params.push(rt);
305
- }
306
- break;
307
- case 'vs':
308
- conditions.push("AND ResourceType = 'ValueSet'");
309
- if (rt && rt !== '' && hasTerminologySource(rt)) {
310
- conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
311
- params.push(rt);
312
- }
313
- break;
314
- case 'cm':
315
- conditions.push("AND ResourceType = 'ConceptMap'");
316
- if (rt && rt !== '' && hasTerminologySource(rt)) {
317
- conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
318
- params.push(rt);
319
- }
320
- break;
321
- default:
322
- if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
323
- conditions.push('AND ResourceType = ?');
324
- params.push(rt);
325
- }
326
- break;
327
- }
328
-
329
- if (text && text !== '') {
330
- const ftsText = text.replace(/"/g, '""');
331
- if (type === 'cs') {
332
- conditions.push(`AND (ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)
333
- OR ResourceKey IN (SELECT ResourceKey FROM CodeSystemFTS WHERE CodeSystemFTS MATCH ?))`);
334
- params.push(`{Description Narrative} : "${ftsText}"`, `{Display Definition} : "${ftsText}"`);
335
- } else {
336
- conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)');
337
- params.push(`{Description Narrative} : "${ftsText}"`);
338
- }
339
- }
340
-
341
- // Package filter - matches packageId#version containing the search string
342
- if (pkg && pkg !== '') {
343
- conditions.push('AND PackageKey IN (SELECT PackageKey FROM Packages WHERE (Id || \'#\' || Version) LIKE ?)');
344
- params.push(`%${pkg}%`);
345
- }
346
-
347
- // Only used filter
348
- if (onlyUsed === 'true') {
349
- conditions.push('AND EXISTS (SELECT 1 FROM DependencyList WHERE TargetKey = Resources.ResourceKey)');
350
- }
351
-
352
- const fullQuery = baseQuery + ' ' + conditions.join(' ');
353
- return { query: fullQuery, params };
252
+ const { where, params } = buildResourceFilter(queryParams);
253
+ return { query: `SELECT COUNT(*) as total FROM Resources ${where}`, params };
354
254
  }
355
255
 
356
256
  // Template Functions
@@ -463,141 +363,6 @@ function buildContentHtml(contentData) {
463
363
  return html;
464
364
  }
465
365
 
466
- // SQL Filter Building Functions
467
-
468
- function sqlEscapeString(str) {
469
- if (!str) return '';
470
- // Escape single quotes for SQL
471
- return str.replace(/'/g, "''");
472
- }
473
-
474
- function buildSqlFilter(queryParams) {
475
- const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
476
- let filter = '';
477
-
478
- // Realm filter
479
- if (realm && realm !== '') {
480
- filter += ` and realm = '${sqlEscapeString(realm)}'`;
481
- }
482
-
483
- // Authority filter
484
- if (auth && auth !== '') {
485
- filter += ` and authority = '${sqlEscapeString(auth)}'`;
486
- }
487
-
488
- // Version filter - check specific version columns
489
- if (ver) {
490
- switch (ver) {
491
- case 'R2':
492
- filter += ' and R2 = 1';
493
- break;
494
- case 'R2B':
495
- filter += ' and R2B = 1';
496
- break;
497
- case 'R3':
498
- filter += ' and R3 = 1';
499
- break;
500
- case 'R4':
501
- filter += ' and R4 = 1';
502
- break;
503
- case 'R4B':
504
- filter += ' and R4B = 1';
505
- break;
506
- case 'R5':
507
- filter += ' and R5 = 1';
508
- break;
509
- case 'R6':
510
- filter += ' and R6 = 1';
511
- break;
512
- }
513
- }
514
-
515
- // Type-specific filters
516
- switch (type) {
517
- case 'cs': // CodeSystem
518
- filter += " and ResourceType = 'CodeSystem'";
519
- break;
520
-
521
- case 'rp': // Resource Profiles
522
- filter += " and ResourceType = 'StructureDefinition' and kind = 'resource'";
523
- if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
524
- filter += ` and Type = '${sqlEscapeString(rt)}'`;
525
- }
526
- break;
527
-
528
- case 'dp': // Datatype Profiles
529
- filter += " and ResourceType = 'StructureDefinition' and (kind = 'complex-type' or kind = 'primitive-type')";
530
- if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
531
- filter += ` and Type = '${sqlEscapeString(rt)}'`;
532
- }
533
- break;
534
-
535
- case 'lm': // Logical Models
536
- filter += " and ResourceType = 'StructureDefinition' and kind = 'logical'";
537
- break;
538
-
539
- case 'ext': // Extensions
540
- filter += " and ResourceType = 'StructureDefinition' and (Type = 'Extension')";
541
- if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
542
- filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 2 and Code = '${sqlEscapeString(rt)}')`;
543
- }
544
- break;
545
-
546
- case 'vs': // ValueSets
547
- filter += " and ResourceType = 'ValueSet'";
548
- if (rt && rt !== '' && hasTerminologySource(rt)) {
549
- filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 1 and Code = '${sqlEscapeString(rt)}')`;
550
- }
551
- break;
552
-
553
- case 'cm': // ConceptMaps
554
- filter += " and ResourceType = 'ConceptMap'";
555
- if (rt && rt !== '' && hasTerminologySource(rt)) {
556
- filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 1 and Code = '${sqlEscapeString(rt)}')`;
557
- }
558
- break;
559
-
560
- default:
561
- // No specific type selected - handle rt parameter for general resource filtering
562
- if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
563
- filter += ` and ResourceType = '${sqlEscapeString(rt)}'`;
564
- }
565
- break;
566
- }
567
-
568
- // Text search filter
569
- if (text && text !== '') {
570
- const escapedText = sqlEscapeString(text);
571
- if (type === 'cs') {
572
- // Special handling for CodeSystems - search both resource and CodeSystem-specific fields
573
- filter += ` and (ResourceKey in (select ResourceKey from ResourceFTS where Description match '"${escapedText}"' or Narrative match '"${escapedText}"') ` +
574
- `or ResourceKey in (select ResourceKey from CodeSystemFTS where Display match '"${escapedText}"' or Definition match '"${escapedText}"'))`;
575
- } else {
576
- // Standard resource text search
577
- filter += ` and ResourceKey in (select ResourceKey from ResourceFTS where Description match '"${escapedText}"' or Narrative match '"${escapedText}"')`;
578
- }
579
- }
580
-
581
- // Package filter - matches packageId#version containing the search string
582
- if (pkg && pkg !== '') {
583
- const escapedPkg = sqlEscapeString(pkg);
584
- filter += ` and PackageKey in (select PackageKey from Packages where (Id || '#' || Version) like '%${escapedPkg}%')`;
585
- }
586
-
587
- // Only used filter
588
- if (onlyUsed === 'true') {
589
- filter += ` and exists (select 1 from DependencyList where TargetKey = Resources.ResourceKey)`;
590
- }
591
-
592
- // Convert to proper WHERE clause
593
- if (filter !== '') {
594
- // Remove the first " and " and prepend "WHERE "
595
- filter = 'WHERE ' + filter.substring(4);
596
- }
597
-
598
- return filter;
599
- }
600
-
601
366
  // Helper function to check if a terminology source exists
602
367
  // This is a placeholder - you might need to implement this based on your data
603
368
  function hasTerminologySource(sourceCode) {
@@ -606,45 +371,6 @@ function hasTerminologySource(sourceCode) {
606
371
  return hasCachedValue('txSources', sourceCode);
607
372
  }
608
373
 
609
- function buildResourceListQuery(queryParams, offset = 0, limit = 50) {
610
- const whereClause = buildSqlFilter(queryParams);
611
-
612
- // Build the complete SQL query
613
- let sql = `
614
- SELECT
615
- ResourceKey,
616
- ResourceType,
617
- Type,
618
- Kind,
619
- Description,
620
- PackageKey,
621
- Realm,
622
- Authority,
623
- R2, R2B, R3, R4, R4B, R5, R6,
624
- Id,
625
- Url,
626
- Version,
627
- Status,
628
- Date,
629
- Name,
630
- Title,
631
- Content,
632
- Supplements,
633
- Details,
634
- FMM,
635
- WG,
636
- StandardsStatus,
637
- Web,
638
- (SELECT COUNT(*) FROM DependencyList WHERE TargetKey = Resources.ResourceKey) AS UsageCount
639
- FROM Resources
640
- ${whereClause}
641
- ORDER BY ResourceType, Type, Description
642
- LIMIT ${limit} OFFSET ${offset}
643
- `;
644
-
645
- return sql.trim();
646
- }
647
-
648
374
  // Resource List Table Functions
649
375
 
650
376
  function buildPaginationControls(count, offset, baseUrl, queryParams) {
@@ -823,10 +549,10 @@ async function buildResourceTable(queryParams, resourceCount, offset = 0) {
823
549
  parts.push('</tr>');
824
550
 
825
551
  const resourceRows = await new Promise((resolve, reject) => {
826
- xigDb.all(resourceQuery, qp, (err, rows) => {
552
+ xigDb.all(resourceQuery, qp, guarded(reject, (err, rows) => {
827
553
  if (err) reject(err);
828
554
  else resolve(rows || []);
829
- });
555
+ }));
830
556
  });
831
557
 
832
558
  // Determine resource type prefix for links
@@ -969,10 +695,10 @@ async function buildResourceTable(queryParams, resourceCount, offset = 0) {
969
695
  async function fetchResourceRows(queryParams, offset = 0, limit = 200) {
970
696
  const { query: resourceQuery, params: qp } = buildSecureResourceQuery(queryParams, offset, limit);
971
697
  return new Promise((resolve, reject) => {
972
- xigDb.all(resourceQuery, qp, (err, rows) => {
698
+ xigDb.all(resourceQuery, qp, guarded(reject, (err, rows) => {
973
699
  if (err) reject(err);
974
700
  else resolve(rows || []);
975
- });
701
+ }));
976
702
  });
977
703
  }
978
704
 
@@ -1093,7 +819,7 @@ async function buildResourceCsv(queryParams) {
1093
819
 
1094
820
  async function buildSummaryStats(queryParams, baseUrl) {
1095
821
  const { ver, auth, realm } = queryParams;
1096
- const currentFilter = buildSqlFilter(queryParams);
822
+ const { where, params } = buildResourceFilter(queryParams);
1097
823
  let html = '';
1098
824
 
1099
825
  if (!xigDb) {
@@ -1110,18 +836,16 @@ async function buildSummaryStats(queryParams, baseUrl) {
1110
836
  const versions = getCachedSet('versions');
1111
837
  for (const version of versions) {
1112
838
  try {
1113
- let sql;
1114
- if (currentFilter === '') {
1115
- sql = `SELECT COUNT(*) as count FROM Resources WHERE ${version} = 1`;
1116
- } else {
1117
- sql = `SELECT COUNT(*) as count FROM Resources ${currentFilter} AND ${version} = 1`;
839
+ if (!VERSION_COLUMNS.includes(version)) {
840
+ continue; // a column name, so only one of the known ones
1118
841
  }
842
+ const sql = `SELECT COUNT(*) as count FROM Resources ${where} AND ${version} = 1`;
1119
843
 
1120
844
  const count = await new Promise((resolve, reject) => {
1121
- xigDb.get(sql, [], (err, row) => {
845
+ xigDb.get(sql, params, guarded(reject, (err, row) => {
1122
846
  if (err) reject(err);
1123
847
  else resolve(row ? row.count : 0);
1124
- });
848
+ }));
1125
849
  });
1126
850
 
1127
851
  const linkUrl = buildVersionLinkUrl(baseUrl, queryParams, version);
@@ -1138,18 +862,13 @@ async function buildSummaryStats(queryParams, baseUrl) {
1138
862
  html += '<p><strong>By Authority</strong></p>';
1139
863
  html += '<ul style="columns: 4; -webkit-columns: 4; -moz-columns: 4">';
1140
864
 
1141
- let sql;
1142
- if (currentFilter === '') {
1143
- sql = 'SELECT Authority, COUNT(*) as count FROM Resources GROUP BY Authority ORDER BY Authority';
1144
- } else {
1145
- sql = `SELECT Authority, COUNT(*) as count FROM Resources ${currentFilter} GROUP BY Authority ORDER BY Authority`;
1146
- }
865
+ const sql = `SELECT Authority, COUNT(*) as count FROM Resources ${where} GROUP BY Authority ORDER BY Authority`;
1147
866
 
1148
867
  const authorityResults = await new Promise((resolve, reject) => {
1149
- xigDb.all(sql, [], (err, rows) => {
868
+ xigDb.all(sql, params, guarded(reject, (err, rows) => {
1150
869
  if (err) reject(err);
1151
870
  else resolve(rows || []);
1152
- });
871
+ }));
1153
872
  });
1154
873
 
1155
874
  authorityResults.forEach(row => {
@@ -1171,18 +890,13 @@ async function buildSummaryStats(queryParams, baseUrl) {
1171
890
  html += '<p><strong>By Realm</strong></p>';
1172
891
  html += '<ul style="columns: 4; -webkit-columns: 4; -moz-columns: 4">';
1173
892
 
1174
- let sql;
1175
- if (currentFilter === '') {
1176
- sql = 'SELECT Realm, COUNT(*) as count FROM Resources GROUP BY Realm ORDER BY Realm';
1177
- } else {
1178
- sql = `SELECT Realm, COUNT(*) as count FROM Resources ${currentFilter} GROUP BY Realm ORDER BY Realm`;
1179
- }
893
+ const sql = `SELECT Realm, COUNT(*) as count FROM Resources ${where} GROUP BY Realm ORDER BY Realm`;
1180
894
 
1181
895
  const realmResults = await new Promise((resolve, reject) => {
1182
- xigDb.all(sql, [], (err, rows) => {
896
+ xigDb.all(sql, params, guarded(reject, (err, rows) => {
1183
897
  if (err) reject(err);
1184
898
  else resolve(rows || []);
1185
- });
899
+ }));
1186
900
  });
1187
901
 
1188
902
  var c = 0;
@@ -1825,7 +1539,7 @@ function validateDatabaseFile(filePath) {
1825
1539
  }
1826
1540
 
1827
1541
  // Try a simple query to ensure the database is accessible
1828
- testDb.get("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1", (err) => {
1542
+ testDb.get("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1", guarded(reject, (err) => {
1829
1543
  testDb.close();
1830
1544
 
1831
1545
  if (err) {
@@ -1833,7 +1547,7 @@ function validateDatabaseFile(filePath) {
1833
1547
  } else {
1834
1548
  resolve();
1835
1549
  }
1836
- });
1550
+ }));
1837
1551
  });
1838
1552
  });
1839
1553
  }
@@ -1861,13 +1575,13 @@ async function loadConfigCache() {
1861
1575
  // Helper function for simple queries
1862
1576
  const executeQuery = (sql, params = []) => {
1863
1577
  return new Promise((resolve, reject) => {
1864
- xigDb.all(sql, params, (err, rows) => {
1578
+ xigDb.all(sql, params, guarded(reject, (err, rows) => {
1865
1579
  if (err) {
1866
1580
  reject(err);
1867
1581
  } else {
1868
1582
  resolve(rows);
1869
1583
  }
1870
- });
1584
+ }));
1871
1585
  });
1872
1586
  };
1873
1587
 
@@ -2031,7 +1745,11 @@ function initializeDatabase() {
2031
1745
  });
2032
1746
  }
2033
1747
 
2034
- async function updateXigDatabase() {
1748
+ /**
1749
+ * Download the database afresh and swap it in.
1750
+ * @param trigger - 'cron' (the daily schedule), 'startup' (no database yet) or 'manual'
1751
+ */
1752
+ async function updateXigDatabase(trigger = 'manual') {
2035
1753
  if (updateInProgress) {
2036
1754
  xigLog.warn('Update already in progress, skipping');
2037
1755
  return;
@@ -2040,7 +1758,7 @@ async function updateXigDatabase() {
2040
1758
  updateInProgress = true;
2041
1759
  const entry = {
2042
1760
  timestamp: new Date(),
2043
- trigger: new Error().stack.includes('cron') ? 'cron' : 'manual',
1761
+ trigger: trigger,
2044
1762
  status: 'started',
2045
1763
  sourceUrl: XIG_DB_URL,
2046
1764
  error: null,
@@ -2067,14 +1785,14 @@ async function updateXigDatabase() {
2067
1785
  entry.status = 'validated';
2068
1786
 
2069
1787
  if (xigDb) {
2070
- await new Promise((resolve) => {
2071
- xigDb.close((err) => {
1788
+ await new Promise((resolve, reject) => {
1789
+ xigDb.close(guarded(reject, (err) => {
2072
1790
  if (err) {
2073
1791
  xigLog.warn(`Warning: Error closing existing database: ${err.message}`);
2074
1792
  }
2075
1793
  xigDb = null;
2076
1794
  resolve();
2077
- });
1795
+ }));
2078
1796
  });
2079
1797
  }
2080
1798
 
@@ -2135,7 +1853,7 @@ router.use(trackRequest);
2135
1853
 
2136
1854
  // Statistics functions
2137
1855
  function getDatabaseTableCounts() {
2138
- return new Promise((resolve) => {
1856
+ return new Promise((resolve, reject) => {
2139
1857
  if (!xigDb) {
2140
1858
  resolve({ packages: 0, resources: 0 });
2141
1859
  return;
@@ -2145,7 +1863,7 @@ function getDatabaseTableCounts() {
2145
1863
  let completedQueries = 0;
2146
1864
  const totalQueries = 2;
2147
1865
 
2148
- xigDb.get('SELECT COUNT(*) as count FROM Packages', [], (err, row) => {
1866
+ xigDb.get('SELECT COUNT(*) as count FROM Packages', [], guarded(reject, (err, row) => {
2149
1867
  if (err) {
2150
1868
  counts.packages = 0;
2151
1869
  } else {
@@ -2156,9 +1874,9 @@ function getDatabaseTableCounts() {
2156
1874
  if (completedQueries === totalQueries) {
2157
1875
  resolve(counts);
2158
1876
  }
2159
- });
1877
+ }));
2160
1878
 
2161
- xigDb.get('SELECT COUNT(*) as count FROM Resources', [], (err, row) => {
1879
+ xigDb.get('SELECT COUNT(*) as count FROM Resources', [], guarded(reject, (err, row) => {
2162
1880
  if (err) {
2163
1881
  counts.resources = 0;
2164
1882
  } else {
@@ -2169,7 +1887,7 @@ function getDatabaseTableCounts() {
2169
1887
  if (completedQueries === totalQueries) {
2170
1888
  resolve(counts);
2171
1889
  }
2172
- });
1890
+ }));
2173
1891
  });
2174
1892
  }
2175
1893
 
@@ -2391,7 +2109,7 @@ function getDatabaseInfo() {
2391
2109
  return;
2392
2110
  }
2393
2111
 
2394
- xigDb.get("SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table'", (err, row) => {
2112
+ xigDb.get("SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table'", guarded(reject, (err, row) => {
2395
2113
  if (err) {
2396
2114
  reject(err);
2397
2115
  } else {
@@ -2402,7 +2120,7 @@ function getDatabaseInfo() {
2402
2120
  fileSize: fs.existsSync(XIG_DB_PATH) ? fs.statSync(XIG_DB_PATH).size : 0
2403
2121
  });
2404
2122
  }
2405
- });
2123
+ }));
2406
2124
  });
2407
2125
  }
2408
2126
 
@@ -2507,13 +2225,13 @@ router.get('/', async (req, res) => {
2507
2225
  if (xigDb) {
2508
2226
  const {query: countQuery, params: countParams} = buildSecureResourceCountQuery(queryParams);
2509
2227
  resourceCount = await new Promise((resolve, reject) => {
2510
- xigDb.get(countQuery, countParams, (err, row) => {
2228
+ xigDb.get(countQuery, countParams, guarded(reject, (err, row) => {
2511
2229
  if (err) {
2512
2230
  reject(err);
2513
2231
  } else {
2514
2232
  resolve(row ? row.total : 0);
2515
2233
  }
2516
- });
2234
+ }));
2517
2235
  });
2518
2236
  }
2519
2237
  } catch (error) {
@@ -2680,10 +2398,10 @@ router.get('/resource/:packagePid/:resourceType/:resourceId', async (req, res) =
2680
2398
  `;
2681
2399
 
2682
2400
  const resourceData = await new Promise((resolve, reject) => {
2683
- xigDb.get(resourceQuery, [packageObj.PackageKey, resourceType, resourceId], (err, row) => {
2401
+ xigDb.get(resourceQuery, [packageObj.PackageKey, resourceType, resourceId], guarded(reject, (err, row) => {
2684
2402
  if (err) reject(err);
2685
2403
  else resolve(row);
2686
- });
2404
+ }));
2687
2405
  });
2688
2406
 
2689
2407
  if (!resourceData) {
@@ -2830,10 +2548,10 @@ async function buildResourceDependencies(resourceData, secure = false) {
2830
2548
  `;
2831
2549
 
2832
2550
  const dependencies = await new Promise((resolve, reject) => {
2833
- xigDb.all(dependenciesQuery, [resourceData.ResourceKey], (err, rows) => {
2551
+ xigDb.all(dependenciesQuery, [resourceData.ResourceKey], guarded(reject, (err, rows) => {
2834
2552
  if (err) reject(err);
2835
2553
  else resolve(rows || []);
2836
- });
2554
+ }));
2837
2555
  });
2838
2556
 
2839
2557
  if (dependencies.length === 0) {
@@ -2855,10 +2573,10 @@ async function buildResourceDependencies(resourceData, secure = false) {
2855
2573
  `;
2856
2574
 
2857
2575
  const uses = await new Promise((resolve, reject) => {
2858
- xigDb.all(usesQuery, [resourceData.ResourceKey], (err, rows) => {
2576
+ xigDb.all(usesQuery, [resourceData.ResourceKey], guarded(reject, (err, rows) => {
2859
2577
  if (err) reject(err);
2860
2578
  else resolve(rows || []);
2861
- });
2579
+ }));
2862
2580
  });
2863
2581
 
2864
2582
  if (uses.length === 0) {
@@ -2895,10 +2613,10 @@ async function buildExtensionExamplesSection(resourceUrl) {
2895
2613
  `;
2896
2614
 
2897
2615
  const extensionExamples = await new Promise((resolve, reject) => {
2898
- xigDb.all(extensionExamplesQuery, [resourceUrl], (err, rows) => {
2616
+ xigDb.all(extensionExamplesQuery, [resourceUrl], guarded(reject, (err, rows) => {
2899
2617
  if (err) reject(err);
2900
2618
  else resolve(rows || []);
2901
- });
2619
+ }));
2902
2620
  });
2903
2621
 
2904
2622
  if (extensionExamples.length === 0) {
@@ -2998,10 +2716,10 @@ async function buildResourceNarrative(resourceKey, packageObj) {
2998
2716
  const contentsQuery = 'SELECT Json FROM Contents WHERE ResourceKey = ?';
2999
2717
 
3000
2718
  const blobData = await new Promise((resolve, reject) => {
3001
- xigDb.get(contentsQuery, [resourceKey], (err, row) => {
2719
+ xigDb.get(contentsQuery, [resourceKey], guarded(reject, (err, row) => {
3002
2720
  if (err) reject(err);
3003
2721
  else resolve(row);
3004
- });
2722
+ }));
3005
2723
  });
3006
2724
 
3007
2725
  if (!blobData || !blobData.Json) {
@@ -3060,10 +2778,10 @@ async function buildResourceSource(resourceKey) {
3060
2778
  const contentsQuery = 'SELECT Json FROM Contents WHERE ResourceKey = ?';
3061
2779
 
3062
2780
  const blobData = await new Promise((resolve, reject) => {
3063
- xigDb.get(contentsQuery, [resourceKey], (err, row) => {
2781
+ xigDb.get(contentsQuery, [resourceKey], guarded(reject, (err, row) => {
3064
2782
  if (err) reject(err);
3065
2783
  else resolve(row);
3066
- });
2784
+ }));
3067
2785
  });
3068
2786
 
3069
2787
  if (!blobData || !blobData.Json) {
@@ -3229,18 +2947,18 @@ async function initializeXigModule(stats, xigConfig) {
3229
2947
  if (!fs.existsSync(XIG_DB_PATH)) {
3230
2948
  xigLog.info('No existing XIG database found, triggering initial download');
3231
2949
  setTimeout(() => {
3232
- updateXigDatabase().catch((err) => xigLog.error('XIG database update failed: ' + err.message));
2950
+ updateXigDatabase('startup').catch((err) => xigLog.error('XIG database update failed: ' + err.message));
3233
2951
  }, 5000);
3234
2952
  }
3235
2953
 
3236
2954
  if (globalStats) {
3237
- globalStats.addTask('XIG Download', describeCron('0 2 * * *'));
2955
+ globalStats.addTask('XIG Download', describeCron(UPDATE_CRON));
3238
2956
  }
3239
2957
  // Check if auto-update is enabled
3240
2958
  // Note: This assumes we're called only when XIG is enabled
3241
2959
  if (xigConfig?.autoUpdate !== false) {
3242
- cron.schedule('0 2 * * *', () => {
3243
- updateXigDatabase().catch((err) => xigLog.error('XIG database update failed: ' + err.message));
2960
+ cron.schedule(UPDATE_CRON, () => {
2961
+ updateXigDatabase('cron').catch((err) => xigLog.error('XIG database update failed: ' + err.message));
3244
2962
  });
3245
2963
  }
3246
2964
 
@@ -3252,9 +2970,105 @@ async function initializeXigModule(stats, xigConfig) {
3252
2970
 
3253
2971
  // Graceful shutdown
3254
2972
  /**
3255
- * The paragraph for the server's daily report: pages served, and whether the
3256
- * database can be read (it's downloaded afresh each day, and a failed download
3257
- * or an unreadable file is what this is for).
2973
+ * The times (ms, local 02:00) the scheduled update was due in (since, now]
2974
+ */
2975
+ function scheduledUpdatesBetween(since, now) {
2976
+ const result = [];
2977
+ const d = new Date(since);
2978
+ d.setHours(UPDATE_HOUR, 0, 0, 0);
2979
+ if (d.getTime() <= since) {
2980
+ d.setDate(d.getDate() + 1);
2981
+ }
2982
+ while (d.getTime() <= now) {
2983
+ result.push(d.getTime());
2984
+ d.setDate(d.getDate() + 1);
2985
+ }
2986
+ return result;
2987
+ }
2988
+
2989
+ /**
2990
+ * The sentences about database updates in the period (since, now] for the daily report:
2991
+ * affirms the scheduled update happened, or says that it failed or did not run.
2992
+ *
2993
+ * @param history - update attempts, newest first (getUpdateHistory())
2994
+ * @param autoUpdate - false if scheduled updates are turned off
2995
+ * @param dbModified - when the database file in use was written (ms), or null if there isn't one
2996
+ */
2997
+ function describeUpdates(history, {since, now, autoUpdate = true, dbModified = null}) {
2998
+ const {localDateTime, formatSpan, formatBytes} = require('../library/daily-report');
2999
+ // just the time of day, unless it's more than a day before the report
3000
+ const time = (t) => now - t <= 24 * 60 * 60 * 1000 ? localDateTime(t).substring(11) : localDateTime(t);
3001
+ const at = (e) => (e.timestamp instanceof Date ? e.timestamp : new Date(e.timestamp)).getTime();
3002
+ const detail = (e) => {
3003
+ const bits = [];
3004
+ if (e.downloadMeta && e.downloadMeta.downloadedBytes) {
3005
+ bits.push(formatBytes(e.downloadMeta.downloadedBytes));
3006
+ }
3007
+ if (e.durationMs != null) {
3008
+ bits.push(`took ${formatSpan(e.durationMs)}`);
3009
+ }
3010
+ return bits.length ? ` (${bits.join(', ')})` : '';
3011
+ };
3012
+
3013
+ const sentences = [];
3014
+ const attempts = (history || []).filter(e => at(e) > since && at(e) <= now).sort((a, b) => at(a) - at(b));
3015
+ const scheduled = attempts.filter(e => e.trigger === 'cron');
3016
+ const others = attempts.filter(e => e.trigger !== 'cron');
3017
+ let updated = attempts.some(e => e.status === 'success');
3018
+
3019
+ if (autoUpdate === false) {
3020
+ sentences.push('Scheduled database updates are turned off.');
3021
+ } else {
3022
+ const due = scheduledUpdatesBetween(since, now);
3023
+ for (const e of scheduled) {
3024
+ if (e.status === 'success') {
3025
+ sentences.push(`The database was updated on schedule at ${time(at(e))}${detail(e)}.`);
3026
+ } else if (e.status === 'failed') {
3027
+ sentences.push(`The scheduled database update at ${time(at(e))} failed${e.error ? ': ' + e.error : ''}.`);
3028
+ } else {
3029
+ sentences.push(`The scheduled database update that started at ${time(at(e))} has not finished.`);
3030
+ }
3031
+ }
3032
+ if (scheduled.length === 0 && due.length > 0) {
3033
+ const last = due[due.length - 1];
3034
+ if (dbModified != null && dbModified >= due[0] && dbModified <= now) {
3035
+ // the record went with a restart, but the file shows it happened
3036
+ sentences.push(`The database was updated at ${time(dbModified)}.`);
3037
+ updated = true;
3038
+ } else {
3039
+ sentences.push(`The database was not updated on schedule: no update ran at ${time(last)} (was the server running then?).`);
3040
+ }
3041
+ }
3042
+ if (scheduled.length > 0 && scheduled.length < due.length) {
3043
+ sentences.push(`${due.length - scheduled.length} of the ${due.length} scheduled updates in this period did not run.`);
3044
+ }
3045
+ if (scheduled.length === 0 && due.length === 0 && attempts.length === 0) {
3046
+ sentences.push('No database update was due in this period.');
3047
+ }
3048
+ }
3049
+ for (const e of others) {
3050
+ const how = e.trigger === 'startup' ? 'initial download' : 'manual update';
3051
+ if (e.status === 'success') {
3052
+ sentences.push(`A ${how} at ${time(at(e))} succeeded${detail(e)}.`);
3053
+ } else if (e.status === 'failed') {
3054
+ sentences.push(`A ${how} at ${time(at(e))} failed${e.error ? ': ' + e.error : ''}.`);
3055
+ } else {
3056
+ sentences.push(`A ${how} started at ${time(at(e))} has not finished.`);
3057
+ }
3058
+ }
3059
+ if (!updated) {
3060
+ if (dbModified == null) {
3061
+ sentences.push('There is no database file.');
3062
+ } else {
3063
+ sentences.push(`The database in use was downloaded ${localDateTime(dbModified)} (${formatSpan(now - dbModified)} ago).`);
3064
+ }
3065
+ }
3066
+ return sentences;
3067
+ }
3068
+
3069
+ /**
3070
+ * The paragraph for the server's daily report: pages served, whether the database
3071
+ * was updated on schedule (it's downloaded afresh each day), and whether it can be read.
3258
3072
  */
3259
3073
  async function dailyReport({since, now}) {
3260
3074
  const {plural} = require('../library/daily-report');
@@ -3263,36 +3077,37 @@ async function dailyReport({since, now}) {
3263
3077
  const counts = globalStats.countsSince(since, now);
3264
3078
  sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
3265
3079
  }
3080
+ const age = getDatabaseAgeInfo();
3081
+ sentences.push(...describeUpdates(getUpdateHistory(), {
3082
+ since, now,
3083
+ autoUpdate: xigSettings?.autoUpdate !== false,
3084
+ dbModified: age.lastDownloaded ? age.lastDownloaded.getTime() : null
3085
+ }));
3266
3086
  if (!xigDb) {
3267
3087
  sentences.push('The database is not open: it could not be accessed.');
3268
3088
  } else {
3269
3089
  try {
3270
3090
  const row = await new Promise((resolve, reject) =>
3271
- xigDb.get('SELECT COUNT(*) AS n FROM Resources', [], (err, row) => err ? reject(err) : resolve(row)));
3091
+ xigDb.get('SELECT COUNT(*) AS n FROM Resources', [], guarded(reject, (err, row) => err ? reject(err) : resolve(row))));
3272
3092
  sentences.push(`The database holds ${plural(row.n, 'resource')}.`);
3273
3093
  } catch (e) {
3274
3094
  sentences.push(`The database could not be accessed: ${e.message}.`);
3275
3095
  }
3276
3096
  }
3277
- const last = getLastUpdateAttempt();
3278
- if (last && last.status === 'failed') {
3279
- const when = last.timestamp instanceof Date ? last.timestamp.toISOString().substring(0, 16).replace('T', ' ') : String(last.timestamp);
3280
- sentences.push(`The last database update (${when}) failed${last.error ? ': ' + last.error : ''}.`);
3281
- }
3282
3097
  return sentences.join(' ');
3283
3098
  }
3284
3099
 
3285
3100
  function shutdown() {
3286
- return new Promise((resolve) => {
3101
+ return new Promise((resolve, reject) => {
3287
3102
  if (xigDb) {
3288
- xigDb.close((err) => {
3103
+ xigDb.close(guarded(reject, (err) => {
3289
3104
  if (err) {
3290
3105
  xigLog.error(`Error closing XIG database: ${err.message}`);
3291
3106
  } else {
3292
3107
  xigLog.error('XIG database connection closed');
3293
3108
  }
3294
3109
  resolve();
3295
- });
3110
+ }));
3296
3111
  } else {
3297
3112
  resolve();
3298
3113
  }
@@ -3363,10 +3178,9 @@ module.exports = {
3363
3178
  buildRealmLinkUrl,
3364
3179
 
3365
3180
  // SQL filter functions
3366
- buildSqlFilter,
3367
- buildResourceListQuery,
3181
+ buildResourceFilter,
3182
+ buildSecureResourceQuery,
3368
3183
  buildSecureResourceCountQuery,
3369
- sqlEscapeString,
3370
3184
  hasTerminologySource,
3371
3185
  gatherPageStatistics,
3372
3186
 
@@ -3377,6 +3191,8 @@ module.exports = {
3377
3191
  buildStatsTable,
3378
3192
  getUpdateHistory,
3379
3193
  getLastUpdateAttempt,
3194
+ describeUpdates,
3195
+ scheduledUpdatesBetween,
3380
3196
 
3381
3197
  // Event emitter
3382
3198
  cacheEmitter