fhirsmith 0.16.1 → 0.18.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 (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
package/shl/shl.js CHANGED
@@ -808,16 +808,26 @@ class SHLModule {
808
808
  }
809
809
  });
810
810
 
811
- // Helper function for the shared access logic
811
+ // Helper function for the shared access logic.
812
+ //
813
+ // POST is the manifest request. GET is only defined by the SHL spec for the
814
+ // U flag (direct file access): the client does GET {url}?recipient=... and
815
+ // expects the single encrypted file (JWE) back, not a manifest. The flag is
816
+ // not sent to or stored on the server, so a GET is taken to mean U. If the
817
+ // SHL has more than one file (U isn't valid for it), or it's a VHL, GET
818
+ // keeps returning the manifest / VHL bundle as before.
812
819
  const handleSHLAccess = (req, res) => {
813
820
  const start = Date.now();
814
821
  try {
815
822
  const {uuid} = req.params;
823
+ const isGet = req.method === 'GET';
816
824
 
817
825
  let recipient, embeddedLengthMax;
818
826
 
819
- if (req.method === 'GET') {
820
- recipient = 'anonymous';
827
+ if (isGet) {
828
+ recipient = typeof req.query.recipient === 'string' && req.query.recipient.length > 0
829
+ ? req.query.recipient.substring(0, 255)
830
+ : 'anonymous';
821
831
  embeddedLengthMax = undefined;
822
832
  } else {
823
833
  ({recipient, embeddedLengthMax} = req.body);
@@ -846,7 +856,7 @@ class SHLModule {
846
856
 
847
857
  const logAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
848
858
 
849
- this.db.run(logAccessSql, [uuid, recipient, clientIP], function (logErr) {
859
+ this.db.run(logAccessSql, [uuid, recipient, clientIP], (logErr) => {
850
860
  if (logErr) {
851
861
  shlLog.error('Failed to log SHL access:', logErr.message);
852
862
  }
@@ -858,24 +868,37 @@ class SHLModule {
858
868
  return res.status(500).json({error: 'Failed to retrieve files'});
859
869
  }
860
870
 
871
+ // U flag: return the one file directly
872
+ if (isGet && !shlRow.vhl && fileRows.length === 1) {
873
+ try {
874
+ res.set('Content-Type', 'application/jose');
875
+ return res.send(Buffer.from(fileRows[0].cnt, 'base64'));
876
+ } catch (decodeErr) {
877
+ return res.status(500).json({error: 'Failed to decode file content'});
878
+ }
879
+ }
880
+
881
+ // SHL links are always https; the server always sits behind an https proxy
861
882
  const host = req.get('host') || 'localhost:3000';
862
- const protocol = req.secure ? 'https' : 'http';
863
883
  const maxLength = embeddedLengthMax ? parseInt(embeddedLengthMax) : undefined;
864
884
 
865
885
  const files = fileRows.map(file => {
866
886
  const fileResponse = {
867
887
  contentType: file.type,
868
- location: `${protocol}://${host}/shl/file/${file.id}`
888
+ location: `https://${host}/shl/file/${file.id}`
869
889
  };
870
890
 
871
- if (maxLength === undefined || file.cnt.length <= maxLength) {
872
- fileResponse.embedded = file.cnt;
891
+ // cnt is stored base64-encoded; embedded must be the JWE compact serialization itself
892
+ const jwe = Buffer.from(file.cnt, 'base64').toString('utf8');
893
+ if (maxLength === undefined || jwe.length <= maxLength) {
894
+ fileResponse.embedded = jwe;
873
895
  }
874
896
 
875
897
  return fileResponse;
876
898
  });
877
899
 
878
- const standardResponse = {files};
900
+ // finalized: content is fixed for the life of the link (expired links 404 above)
901
+ const standardResponse = {status: 'finalized', files};
879
902
 
880
903
  if (shlRow.vhl && vhlProcessor) {
881
904
  try {
@@ -1059,6 +1082,38 @@ class SHLModule {
1059
1082
  });
1060
1083
  }
1061
1084
 
1085
+ /**
1086
+ * The paragraph for the server's daily report: links (QR codes) created in
1087
+ * the period, in either form, how often links were accessed, and the
1088
+ * documents stored behind them.
1089
+ */
1090
+ async dailyReport({since, now}) {
1091
+ const {plural} = require('../library/daily-report');
1092
+ if (!this.db) {
1093
+ return 'The database is not open.';
1094
+ }
1095
+ const get = (sql, params) => new Promise((resolve, reject) =>
1096
+ this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
1097
+ // created_at is CURRENT_TIMESTAMP: UTC, 'YYYY-MM-DD HH:MM:SS'
1098
+ const utc = ms => new Date(ms).toISOString().replace('T', ' ').substring(0, 19);
1099
+ try {
1100
+ const created = await get(
1101
+ 'SELECT SUM(CASE WHEN vhl THEN 0 ELSE 1 END) AS shl, SUM(CASE WHEN vhl THEN 1 ELSE 0 END) AS vhl FROM SHL WHERE created_at > ? AND created_at <= ?',
1102
+ [utc(since), utc(now)]);
1103
+ const views = await get('SELECT COUNT(*) AS n FROM SHLViews WHERE created_at > ? AND created_at <= ?', [utc(since), utc(now)]);
1104
+ const files = await get('SELECT COUNT(*) AS n, COUNT(DISTINCT shl_uuid) AS links FROM SHLFiles', []);
1105
+ const shl = created.shl || 0;
1106
+ const vhl = created.vhl || 0;
1107
+ const total = shl + vhl;
1108
+ const sentences = [];
1109
+ sentences.push(`${plural(total, 'QR code')} ${total === 1 ? 'was' : 'were'} served (${shl} SMART Health ${shl === 1 ? 'Link' : 'Links'}, ${plural(vhl, 'Verifiable Health Link')}), and links were accessed ${plural(views.n, 'time')}.`);
1110
+ sentences.push(`${plural(files.n, 'document')} ${files.n === 1 ? 'is' : 'are'} stored to serve ${plural(files.links, 'QR code')}.`);
1111
+ return sentences.join(' ');
1112
+ } catch (e) {
1113
+ return `The database could not be read: ${e.message}.`;
1114
+ }
1115
+ }
1116
+
1062
1117
  async shutdown() {
1063
1118
  shlLog.info('Shutting down SHL module...');
1064
1119
 
package/stats-db.js CHANGED
@@ -77,6 +77,22 @@ class StatsDatabase {
77
77
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
78
78
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
79
79
 
80
+ // One row per run of the server: when it started serving, and the last
81
+ // time it was known to be alive. See ServerStats.overallUptime().
82
+ this.db.exec(`
83
+ CREATE TABLE IF NOT EXISTS server_runs (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ started INTEGER NOT NULL,
86
+ last_seen INTEGER NOT NULL,
87
+ version TEXT
88
+ )
89
+ `);
90
+ // version was added after the table first shipped
91
+ if (!this.db.prepare(`PRAGMA table_info(server_runs)`).all().some(c => c.name === 'version')) {
92
+ this.db.exec(`ALTER TABLE server_runs ADD COLUMN version TEXT`);
93
+ }
94
+ this.touchRunStatement = this.db.prepare(`UPDATE server_runs SET last_seen = ? WHERE id = ?`);
95
+
80
96
  this.insertStatement = this.db.prepare(`
81
97
  INSERT INTO request_counts
82
98
  (time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
@@ -106,6 +122,83 @@ class StatsDatabase {
106
122
  `).all();
107
123
  }
108
124
 
125
+ /**
126
+ * Record that a new run of the server has started serving, and return its
127
+ * row id for touchRun().
128
+ */
129
+ startRun(now, version = null) {
130
+ if (!this.db) {
131
+ return null;
132
+ }
133
+ return this.db.prepare(`INSERT INTO server_runs (started, last_seen, version) VALUES (?, ?, ?)`).run(now, now, version).lastInsertRowid;
134
+ }
135
+
136
+ /**
137
+ * The runs that were alive at any point since `since`, oldest first, plus
138
+ * the last run that ended before then (so a caller can tell whether the
139
+ * first run in the period was an upgrade). Each is {id, started, lastSeen,
140
+ * version}.
141
+ */
142
+ loadRuns(since) {
143
+ if (!this.db) {
144
+ return [];
145
+ }
146
+ const before = this.db.prepare(`
147
+ SELECT id, started, last_seen AS lastSeen, version FROM server_runs
148
+ WHERE last_seen < ? ORDER BY last_seen DESC LIMIT 1
149
+ `).get(since);
150
+ const during = this.db.prepare(`
151
+ SELECT id, started, last_seen AS lastSeen, version FROM server_runs
152
+ WHERE last_seen >= ? ORDER BY started
153
+ `).all(since);
154
+ return before ? [before, ...during] : during;
155
+ }
156
+
157
+ /**
158
+ * The server is still alive: move the run's last_seen forward.
159
+ */
160
+ touchRun(runId, now) {
161
+ if (this.db && runId != null) {
162
+ this.touchRunStatement.run(now, runId);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * How long the server has been running over all the runs recorded before
168
+ * this one: the earliest start, and the total time between each run's start
169
+ * and the last time it was seen alive. {firstStart: null, runningMs: 0} if
170
+ * there are no earlier runs.
171
+ */
172
+ loadRunHistory(excludeRunId) {
173
+ if (!this.db) {
174
+ return {firstStart: null, runningMs: 0};
175
+ }
176
+ const row = this.db.prepare(`
177
+ SELECT MIN(started) AS firstStart, COALESCE(SUM(last_seen - started), 0) AS runningMs
178
+ FROM server_runs
179
+ WHERE id IS NOT ?
180
+ `).get(excludeRunId == null ? -1 : excludeRunId);
181
+ return {firstStart: row.firstStart, runningMs: row.runningMs};
182
+ }
183
+
184
+ /**
185
+ * The requests counted against a module in (since, now], by endpoint and
186
+ * operation: [{endpoint, operation, count}]. A row is attributed to the
187
+ * time it was written, so the edges of the period are as fine as the
188
+ * collection interval (and ServerStats.flushNow() sharpens the near edge).
189
+ */
190
+ countsSince(since, now, module) {
191
+ if (!this.db) {
192
+ return [];
193
+ }
194
+ return this.db.prepare(`
195
+ SELECT endpoint, operation, SUM(count_delta) AS count
196
+ FROM request_counts
197
+ WHERE module = ? AND time > ? AND time <= ?
198
+ GROUP BY endpoint, operation
199
+ `).all(module, since, now);
200
+ }
201
+
109
202
  /**
110
203
  * Write one interval's worth of rows. All or nothing - if the transaction
111
204
  * fails the caller keeps its deltas and tries again next interval.
package/stats.js CHANGED
@@ -43,6 +43,15 @@ class ModuleStats {
43
43
  get staticRequestCount() { return this.parent.staticRequestCount; }
44
44
  get history() { return this.parent.history; }
45
45
  get startTime() { return this.parent.startTime; }
46
+
47
+ /**
48
+ * What this module served in (since, now]: {total, operations: {name: count},
49
+ * endpoints: {endpoint: {name: count}}} - see ServerStats.countsSince(). This
50
+ * is what a module's dailyReport() builds its numbers from.
51
+ */
52
+ countsSince(since, now = Date.now()) {
53
+ return this.parent.countsSince(since, now, this.module);
54
+ }
46
55
  }
47
56
 
48
57
  class ServerStats {
@@ -62,6 +71,13 @@ class ServerStats {
62
71
  // key (module/endpoint/operation) -> counter. See countRequest().
63
72
  counters = new Map();
64
73
  db = null;
74
+ // this run's row in server_runs, and what the earlier runs add up to - see overallUptime()
75
+ runId = null;
76
+ earlierRuns = {firstStart: null, runningMs: 0};
77
+ // how often this run says "still alive"; a crash loses at most this much
78
+ // from the overall uptime figure
79
+ heartbeatMs = 60 * 1000;
80
+ heartbeat = null;
65
81
 
66
82
  constructor(config, log) {
67
83
  this.config = config || {};
@@ -146,16 +162,94 @@ class ServerStats {
146
162
  }
147
163
  }
148
164
 
149
- markStarted() {
165
+ markStarted(version = null) {
150
166
  this.started = true;
167
+ this.version = version;
151
168
  this.startMem = process.memoryUsage().heapUsed;
152
169
  this.startTime = Date.now();
153
170
  this.lastTime = this.startTime;
154
171
  this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
155
172
  this.eventLoopMonitor.enable();
173
+ this.startRun();
156
174
  this.recordMetrics();
157
175
  }
158
176
 
177
+ /**
178
+ * Record this run in the database and start the heartbeat that keeps its
179
+ * last_seen current. As with everything else here, a failure is logged and
180
+ * the overall uptime just isn't available for this run.
181
+ */
182
+ startRun() {
183
+ if (!this.db) {
184
+ return;
185
+ }
186
+ try {
187
+ this.runId = this.db.startRun(this.startTime, this.version);
188
+ this.earlierRuns = this.db.loadRunHistory(this.runId);
189
+ } catch (e) {
190
+ this.log.error(`Unable to record the server run: ${e.message}. Overall uptime will not be available`);
191
+ this.runId = null;
192
+ return;
193
+ }
194
+ this.heartbeat = setInterval(() => this.touchRun(), this.heartbeatMs);
195
+ if (typeof this.heartbeat.unref === 'function') {
196
+ this.heartbeat.unref();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The server's runs since `since`, oldest first, each {started, lastSeen,
202
+ * version, current}, preceded by the run before that (if any) - see
203
+ * StatsDatabase.loadRuns(). The current run is reported as seen now, not as
204
+ * of its last heartbeat. null if the history isn't being kept.
205
+ */
206
+ runHistory(since, now = Date.now()) {
207
+ if (this.runId == null) {
208
+ return null;
209
+ }
210
+ let runs;
211
+ try {
212
+ runs = this.db.loadRuns(since);
213
+ } catch (e) {
214
+ this.log.error(`Unable to read the server run history: ${e.message}`);
215
+ return null;
216
+ }
217
+ return runs.map(r => r.id === this.runId
218
+ ? {started: r.started, lastSeen: now, version: r.version, current: true}
219
+ : {started: r.started, lastSeen: r.lastSeen, version: r.version, current: false});
220
+ }
221
+
222
+ touchRun() {
223
+ if (this.db && this.runId != null) {
224
+ try {
225
+ this.db.touchRun(this.runId, Date.now());
226
+ } catch (e) {
227
+ this.log.error(`Unable to record the server heartbeat: ${e.message}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The proportion of time the server has been up, over every run recorded
234
+ * in the statistics database: time it was known to be running (each run,
235
+ * from its start to the last heartbeat it wrote, plus this run up to now)
236
+ * as a percentage of the time since the first recorded run started.
237
+ *
238
+ * Returns {percent, since} or null if the history isn't being kept.
239
+ */
240
+ overallUptime(now = Date.now()) {
241
+ if (this.runId == null) {
242
+ return null;
243
+ }
244
+ const since = this.earlierRuns.firstStart == null ? this.startTime : Math.min(this.earlierRuns.firstStart, this.startTime);
245
+ const elapsed = now - since;
246
+ if (elapsed <= 0) {
247
+ return {percent: 100, since: since};
248
+ }
249
+ const running = this.earlierRuns.runningMs + (now - this.startTime);
250
+ return {percent: Math.min(100, (running * 100) / elapsed), since: since};
251
+ }
252
+
159
253
  /**
160
254
  * The counter for one (module, endpoint, operation), created on first use.
161
255
  *
@@ -215,6 +309,7 @@ class ServerStats {
215
309
  }
216
310
  }
217
311
  if (rows.length === 0) {
312
+ this.lastFlush = now;
218
313
  return 0;
219
314
  }
220
315
  try {
@@ -227,9 +322,53 @@ class ServerStats {
227
322
  counter.countDelta = 0;
228
323
  counter.timeDelta = 0;
229
324
  }
325
+ this.lastFlush = now;
230
326
  return rows.length;
231
327
  }
232
328
 
329
+ /**
330
+ * Write out whatever has been counted since the last flush, so that a
331
+ * countsSince() up to `now` sees everything. Used by the daily report.
332
+ */
333
+ flushNow(now = Date.now()) {
334
+ this.flushCounters(now, now - (this.lastFlush || this.lastTime || this.startTime));
335
+ }
336
+
337
+ /**
338
+ * The requests a module served in (since, now], from the persisted rows plus
339
+ * anything counted since the last flush. Without the database only the
340
+ * unflushed counts are known, so persisted is false to say the numbers are
341
+ * partial.
342
+ */
343
+ countsSince(since, now, module) {
344
+ const result = {total: 0, operations: {}, endpoints: {}, persisted: Boolean(this.db)};
345
+ const add = (endpoint, operation, count) => {
346
+ if (!count) {
347
+ return;
348
+ }
349
+ result.total += count;
350
+ result.operations[operation] = (result.operations[operation] || 0) + count;
351
+ const ep = result.endpoints[endpoint] || (result.endpoints[endpoint] = {});
352
+ ep[operation] = (ep[operation] || 0) + count;
353
+ };
354
+ if (this.db) {
355
+ try {
356
+ for (const row of this.db.countsSince(since, now, module)) {
357
+ add(row.endpoint, row.operation, row.count);
358
+ }
359
+ } catch (e) {
360
+ this.log.error(`Unable to read request counts: ${e.message}`);
361
+ result.persisted = false;
362
+ }
363
+ }
364
+ for (const counter of this.counters.values()) {
365
+ if (counter.module === module) {
366
+ add(counter.endpoint, counter.operation, counter.countDelta);
367
+ }
368
+ }
369
+ return result;
370
+ }
371
+
233
372
  addTask(name, frequency) {
234
373
  let info = {};
235
374
  this.taskMap.set(name, info);
@@ -265,6 +404,19 @@ class ServerStats {
265
404
  }
266
405
  }
267
406
 
407
+ /**
408
+ * The background tasks whose last run failed: [{name, state, frequency, date}]
409
+ */
410
+ failedTasks() {
411
+ const failed = [];
412
+ for (const [name, info] of this.taskMap) {
413
+ if (info.status === 'error') {
414
+ failed.push({name: name, state: info.state, frequency: info.frequency, date: info.date});
415
+ }
416
+ }
417
+ return failed;
418
+ }
419
+
268
420
  taskDetails() {
269
421
  if (this.taskMap.size == 0) {
270
422
  return "";
@@ -290,6 +442,11 @@ class ServerStats {
290
442
 
291
443
  finishStats() {
292
444
  clearInterval(this.timer);
445
+ if (this.heartbeat) {
446
+ clearInterval(this.heartbeat);
447
+ this.heartbeat = null;
448
+ }
449
+ this.touchRun();
293
450
  // Synchronous, so the last partial interval is on disk before server.js
294
451
  // calls process.exit().
295
452
  this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
package/testing/readme.md CHANGED
@@ -31,7 +31,7 @@ shown on any page.
31
31
 
32
32
  FHIRsmith's own tx test run writes its results as `test-cases-report.json` in the
33
33
  FHIRsmith root; `node utilities/submit-test-report.js [-server url] [-token token]` sends it
34
- to a /testing server (by default https://testing.fhir.org/testing).
34
+ to a /testing server (by default https://npm.fhir.org/testing).
35
35
 
36
36
  ## Reading and searching
37
37
 
package/testing/search.js CHANGED
@@ -60,113 +60,7 @@ const MODIFIERS = {
60
60
  // parameters that control the search rather than filter it
61
61
  const CONTROL = new Set(['_sort', '_count', '_offset', '_summary', '_format', '_total', '_pretty']);
62
62
 
63
- const PREFIXES = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'sa', 'eb', 'ap'];
64
-
65
- const DATE_RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
66
-
67
- /**
68
- * The range of instants a FHIR date, dateTime or instant covers, as [lo, hi) in epoch
69
- * milliseconds. "2026" covers the whole year; "2026-09-24T10:00:00.5Z" covers 100ms.
70
- * A time with no zone is taken as UTC (FHIR requires a zone, but search values often
71
- * leave it off).
72
- *
73
- * @param {string} value
74
- * @returns {{lo: number, hi: number}|null} null if the value isn't a date
75
- */
76
- function dateRange(value) {
77
- if (typeof value !== 'string') {
78
- return null;
79
- }
80
- const m = DATE_RE.exec(value.trim());
81
- if (!m) {
82
- return null;
83
- }
84
- const [, y, mo, d, h, mi, s, frac, tz] = m;
85
- const year = parseInt(y, 10);
86
- if (!mo) {
87
- return { lo: Date.UTC(year, 0, 1), hi: Date.UTC(year + 1, 0, 1) };
88
- }
89
- const month = parseInt(mo, 10) - 1;
90
- if (month < 0 || month > 11) {
91
- return null;
92
- }
93
- if (!d) {
94
- return { lo: Date.UTC(year, month, 1), hi: Date.UTC(year, month + 1, 1) };
95
- }
96
- const day = parseInt(d, 10);
97
- if (day < 1 || day > new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) {
98
- return null;
99
- }
100
- if (!h) {
101
- return { lo: Date.UTC(year, month, day), hi: Date.UTC(year, month, day + 1) };
102
- }
103
- const zone = tz || 'Z';
104
- const text = `${y}-${mo}-${d}T${h}:${mi}:${s || '00'}${frac || ''}${zone}`;
105
- const lo = Date.parse(text);
106
- if (Number.isNaN(lo) || parseInt(h, 10) > 23 || parseInt(mi, 10) > 59 || (s && parseInt(s, 10) > 59)) {
107
- return null;
108
- }
109
- let width;
110
- if (!s) {
111
- width = 60000;
112
- } else if (!frac) {
113
- width = 1000;
114
- } else {
115
- // .5 is 100ms wide, .50 10ms, .500 and beyond 1ms
116
- width = Math.max(1, Math.pow(10, 3 - (frac.length - 1)));
117
- }
118
- return { lo, hi: lo + width };
119
- }
120
-
121
- function splitPrefix(value) {
122
- const p = value.substring(0, 2);
123
- if (PREFIXES.includes(p) && value.length > 2 && !/^[a-z]/.test(value.substring(2))) {
124
- return { prefix: p, rest: value.substring(2) };
125
- }
126
- return { prefix: 'eq', rest: value };
127
- }
128
-
129
- function escapeLike(value) {
130
- return value.replace(/[\\%_]/g, (c) => '\\' + c);
131
- }
132
-
133
- /**
134
- * Split a parameter value on commas, honouring \, as an escaped comma.
135
- */
136
- function splitValues(value) {
137
- const out = [];
138
- let cur = '';
139
- for (let i = 0; i < value.length; i++) {
140
- const c = value[i];
141
- if (c === '\\' && i + 1 < value.length) {
142
- cur += value[i + 1];
143
- i++;
144
- } else if (c === ',') {
145
- out.push(cur);
146
- cur = '';
147
- } else {
148
- cur += c;
149
- }
150
- }
151
- out.push(cur);
152
- return out.filter(v => v.length > 0);
153
- }
154
-
155
- function dateClause(def, prefix, range) {
156
- const { lo, hi } = def;
157
- switch (prefix) {
158
- case 'eq': return { sql: `(${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
159
- case 'ne': return { sql: `NOT (${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
160
- case 'gt': return { sql: `${hi} > ?`, args: [range.hi] };
161
- case 'lt': return { sql: `${lo} < ?`, args: [range.lo] };
162
- case 'ge': return { sql: `${hi} > ?`, args: [range.lo] };
163
- case 'le': return { sql: `${lo} < ?`, args: [range.hi] };
164
- case 'sa': return { sql: `${lo} >= ?`, args: [range.hi] };
165
- case 'eb': return { sql: `${hi} <= ?`, args: [range.lo] };
166
- case 'ap': return { sql: `(${lo} < ? AND ${hi} > ?)`, args: [range.hi, range.lo] };
167
- }
168
- return null;
169
- }
63
+ const { dateRange, splitPrefix, escapeLike, splitValues, dateClause: dateRangeClause } = require('../library/fhir-search-values');
170
64
 
171
65
  function numberClause(col, prefix, text) {
172
66
  if (!/^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text)) {
@@ -230,7 +124,7 @@ function valueClause(def, modifier, value) {
230
124
  case 'date': {
231
125
  const { prefix, rest } = splitPrefix(value);
232
126
  const range = dateRange(rest);
233
- return range ? dateClause(def, prefix, range) : null;
127
+ return range ? dateRangeClause(def, prefix, range) : null;
234
128
  }
235
129
  }
236
130
  return null;
package/testing/store.js CHANGED
@@ -256,6 +256,11 @@ class TestReportStore {
256
256
  return this.stmts.count.get().n;
257
257
  }
258
258
 
259
+ /** How many reports were received in (sinceMs, nowMs] */
260
+ countReceived(sinceMs, nowMs) {
261
+ return this.db.prepare('SELECT COUNT(*) AS n FROM reports WHERE received_ms > ? AND received_ms <= ?').get(sinceMs, nowMs).n;
262
+ }
263
+
259
264
  /**
260
265
  * Run a parsed search (see search.js).
261
266
  *
@@ -286,6 +286,26 @@ class TestingModule {
286
286
  this.retentionTimer.unref();
287
287
  }
288
288
 
289
+ /**
290
+ * The paragraph for the server's daily report: pages served, reports received
291
+ * in the period, and how many are held.
292
+ */
293
+ dailyReport({since, now}) {
294
+ const {plural} = require('../library/daily-report');
295
+ const sentences = [];
296
+ if (this.stats) {
297
+ const counts = this.stats.countsSince(since, now);
298
+ sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
299
+ }
300
+ if (this.store) {
301
+ const received = this.store.countReceived(since, now);
302
+ sentences.push(`${plural(received, 'test report')} ${received === 1 ? 'was' : 'were'} received; ${plural(this.store.count(), 'report')} ${this.store.count() === 1 ? 'is' : 'are'} held.`);
303
+ } else {
304
+ sentences.push('The report store is not open.');
305
+ }
306
+ return sentences.join(' ');
307
+ }
308
+
289
309
  async shutdown() {
290
310
  if (this.retentionTimer) {
291
311
  clearInterval(this.retentionTimer);
@@ -150,23 +150,24 @@
150
150
 
151
151
  <div class="api-docs">
152
152
  <h4>API Documentation</h4>
153
- <p>Use these endpoints to validate and track API key usage:</p>
153
+ <p>Use these endpoints to validate and track API key usage. Send the key in a header:
154
+ <code>Authorization: Bearer {api_key}</code> (or <code>X-API-Key: {api_key}</code>).</p>
154
155
 
155
156
  <div class="mb-3">
156
157
  <strong>Validate API Key:</strong>
157
- <div class="api-endpoint">GET /token/api/validate/{api_key}</div>
158
+ <div class="api-endpoint">GET /token/api/validate</div>
158
159
  <p>Returns user information and allowed request count.</p>
159
160
  </div>
160
161
 
161
162
  <div class="mb-3">
162
163
  <strong>Record Usage:</strong>
163
- <div class="api-endpoint">POST /token/api/usage/{api_key}</div>
164
+ <div class="api-endpoint">POST /token/api/usage</div>
164
165
  <p>Records API usage. Send <code>{"count": 1}</code> in request body.</p>
165
166
  </div>
166
167
 
167
168
  <div class="mb-3">
168
169
  <strong>Get Usage Statistics:</strong>
169
- <div class="api-endpoint">GET /token/api/stats/{api_key}?days=30</div>
170
+ <div class="api-endpoint">GET /token/api/stats?days=30</div>
170
171
  <p>Returns usage statistics for the specified number of days.</p>
171
172
  </div>
172
173
  </div>