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
@@ -0,0 +1,1050 @@
1
+ //
2
+ // SqliteResourceCache - the $cache-control resource cache kept in one SQLite file
3
+ // that every server process on the machine shares.
4
+ //
5
+ // Used when server.cluster.sharedCache is on (cluster.md, "Options"): the master and the
6
+ // tx-workers then keep their $cache-control caches here instead of in their own memory, so
7
+ // any process can answer any cache-id. The memory store (ResourceCache) is the default.
8
+ // This class is the same interface with the same semantics, over a file:
9
+ //
10
+ // - Caches are not carried over a server restart. A client can't be sure a cache is
11
+ // intact across operations it tried while the server was down, and the server can't
12
+ // either. So whenever the master starts (under the launcher or by hand), every live
13
+ // cache, in every endpoint, becomes a tombstone "lost to a server restart", and a new
14
+ // generation begins. A tx-worker restarting while the master carries on joins the
15
+ // current generation and keeps every cache. Cache-ids carry their generation
16
+ // ("<code>.<generation>.<random>"); writes and lazy reads check it inside their
17
+ // transaction, and the generations of earlier runs are remembered, so an id from
18
+ // before a restart is reported as lost to it, not as never issued.
19
+ // - Nothing about a cache's contents is held in memory between requests. Each use
20
+ // reads the rows; there is no per-process copy that could go stale when another
21
+ // process changes the cache.
22
+ // - A request only pays for the resources it uses. get() reads the list of what the
23
+ // cache holds (type, url, version - not the content) and returns a stand-in for
24
+ // each CodeSystem / ValueSet / ConceptMap that answers those from the list; the
25
+ // first time a request touches anything else on one, its JSON is read and the
26
+ // real wrapper built (see lazyResource). Most requests look up a handful of
27
+ // resources by url in a cache of tens or hundreds.
28
+ // - Every write is one short BEGIN IMMEDIATE transaction; WAL mode lets readers
29
+ // carry on while one process writes; busy_timeout makes a writer wait for another
30
+ // rather than fail.
31
+ // - last_used is refreshed at most every touchIntervalMs (default 30 s) per cache,
32
+ // not on every read, so steady use of a cache is reads only.
33
+ // - The idle sweep (prune) and the size cap (maxBytes) move the caches they drop to
34
+ // tombstones in the same transaction, so whichever process asks about a dead
35
+ // cache-id later is told what happened to it. Any process may sweep; a sweep that
36
+ // finds nothing to do (because another process just did it) does nothing.
37
+ //
38
+ // The file must be on a local disk. SQLite's locking is not reliable on network
39
+ // filesystems (NFS, SMB, most container volume drivers backed by them), and a
40
+ // broken lock there corrupts the file.
41
+ //
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+ const crypto = require('crypto');
46
+ const { newCacheGeneration, TOKEN } = require('../library/cache-generation');
47
+ const Database = require('better-sqlite3');
48
+ const { ResourceCache, formatDuration } = require('./operation-context');
49
+ const { Issue } = require('./library/operation-outcome');
50
+ const { englishMessage } = require('../library/i18nsupport');
51
+
52
+ // An Issue with its message id and parameters, in English (the worker puts it in the
53
+ // request's language where it can: msgParams, translateCacheIssue)
54
+ function cacheIssue(level, cause, messageId, params, txIssueType, status) {
55
+ const issue = new Issue(level, cause, null, messageId, englishMessage(messageId, params), txIssueType, status);
56
+ issue.msgParams = params;
57
+ return issue;
58
+ }
59
+
60
+ const SCHEMA_VERSION = 2;
61
+ const DEFAULT_TOUCH_INTERVAL_MS = 30 * 1000;
62
+ const DEFAULT_BUSY_TIMEOUT_MS = 5000;
63
+ // how many earlier generations are remembered, to report their ids as lost to a restart
64
+ const MAX_PAST_GENERATIONS = 1000;
65
+
66
+ /**
67
+ * Render a byte count for a human: "512 KB", "3.4 MB", "2 GB".
68
+ * @param {number} bytes
69
+ * @returns {string}
70
+ */
71
+ function formatBytes(bytes) {
72
+ if (bytes === null || bytes === undefined || !isFinite(bytes) || bytes < 0) {
73
+ return 'an unknown size';
74
+ }
75
+ if (bytes < 1024 * 1024) {
76
+ return `${Math.max(1, Math.round(bytes / 1024))} KB`;
77
+ }
78
+ if (bytes < 1024 * 1024 * 1024) {
79
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB`;
80
+ }
81
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1).replace(/\.0$/, '')} GB`;
82
+ }
83
+
84
+ // One connection per file per process, shared by the endpoints (each has its own scope)
85
+ const connections = new Map(); // absolute path -> { db, refs }
86
+
87
+ function openDatabase(file, busyTimeoutMs) {
88
+ const abs = path.resolve(file);
89
+ const existing = connections.get(abs);
90
+ if (existing) {
91
+ existing.refs++;
92
+ return existing.db;
93
+ }
94
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
95
+ const db = new Database(abs);
96
+ // busy_timeout first: switching to WAL and creating the tables need locks that
97
+ // another process starting at the same moment may hold
98
+ db.pragma(`busy_timeout = ${busyTimeoutMs}`);
99
+ db.pragma('journal_mode = WAL');
100
+ // NORMAL is safe with WAL (no corruption); a power cut can lose the last
101
+ // transactions, which for a cache of client resources is acceptable
102
+ db.pragma('synchronous = NORMAL');
103
+ db.transaction(() => {
104
+ db.exec(`
105
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
106
+ CREATE TABLE IF NOT EXISTS sessions (
107
+ scope TEXT NOT NULL,
108
+ cache_id TEXT NOT NULL,
109
+ created_at INTEGER NOT NULL,
110
+ last_used INTEGER NOT NULL,
111
+ sealed INTEGER NOT NULL DEFAULT 0,
112
+ concepts INTEGER NOT NULL DEFAULT 0,
113
+ resources INTEGER NOT NULL DEFAULT 0,
114
+ bytes INTEGER NOT NULL DEFAULT 0,
115
+ version INTEGER NOT NULL DEFAULT 0,
116
+ PRIMARY KEY (scope, cache_id)
117
+ );
118
+ CREATE INDEX IF NOT EXISTS sessions_last_used ON sessions (last_used);
119
+ CREATE TABLE IF NOT EXISTS resources (
120
+ id INTEGER PRIMARY KEY,
121
+ scope TEXT NOT NULL,
122
+ cache_id TEXT NOT NULL,
123
+ key TEXT NOT NULL,
124
+ resource_type TEXT,
125
+ url TEXT,
126
+ version TEXT,
127
+ fhir_version TEXT,
128
+ kind TEXT NOT NULL,
129
+ hash TEXT NOT NULL,
130
+ json TEXT NOT NULL,
131
+ bytes INTEGER NOT NULL,
132
+ concepts INTEGER NOT NULL DEFAULT 0,
133
+ supplements TEXT,
134
+ global_supplement INTEGER,
135
+ UNIQUE (scope, cache_id, key)
136
+ );
137
+ CREATE TABLE IF NOT EXISTS generations (
138
+ generation TEXT PRIMARY KEY,
139
+ started INTEGER NOT NULL,
140
+ ended INTEGER
141
+ );
142
+ CREATE TABLE IF NOT EXISTS tombstones (
143
+ scope TEXT NOT NULL,
144
+ cache_id TEXT NOT NULL,
145
+ reason TEXT NOT NULL,
146
+ at INTEGER NOT NULL,
147
+ created_at INTEGER,
148
+ last_used INTEGER,
149
+ idle_ms INTEGER,
150
+ max_age_ms INTEGER,
151
+ max_bytes INTEGER,
152
+ PRIMARY KEY (scope, cache_id)
153
+ );
154
+ `);
155
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
156
+ if (!row) {
157
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
158
+ } else if (row.value !== String(SCHEMA_VERSION)) {
159
+ throw new Error(`Resource cache ${abs} has schema version ${row.value}; this server uses ${SCHEMA_VERSION}. Delete the file (it only holds client caches) and restart`);
160
+ }
161
+ }).immediate();
162
+ connections.set(abs, { db, refs: 1 });
163
+ return db;
164
+ }
165
+
166
+ function releaseDatabase(file) {
167
+ const abs = path.resolve(file);
168
+ const c = connections.get(abs);
169
+ if (!c) {
170
+ return;
171
+ }
172
+ c.refs--;
173
+ if (c.refs <= 0) {
174
+ connections.delete(abs);
175
+ try {
176
+ c.db.close();
177
+ } catch (e) {
178
+ // closing is best effort
179
+ }
180
+ }
181
+ }
182
+
183
+ // The wrapper classes are loaded on first use: they pull in a good deal of the server
184
+ let wrappers = null;
185
+ function wrapperClass(resourceType) {
186
+ if (!wrappers) {
187
+ wrappers = {
188
+ CodeSystem: require('./library/codesystem').CodeSystem,
189
+ ValueSet: require('./library/valueset'),
190
+ ConceptMap: require('./library/conceptmap').ConceptMap
191
+ };
192
+ }
193
+ return wrappers[resourceType] || null;
194
+ }
195
+
196
+ /**
197
+ * Content hash of a resource's JSON, as the expansion cache key uses it
198
+ * (CanonicalResource.computeExpansionKeyHash: the first 16 hex characters of a SHA-256
199
+ * of JSON.stringify(jsonObj)). Stored with the row, and handed to each wrapper built from
200
+ * it (setExpansionKeyHash), so a cached resource is never serialised again to be hashed
201
+ * and its expansion keys are the ones the memory store would make.
202
+ * @param {string} json
203
+ * @returns {string}
204
+ */
205
+ function contentHash(json) {
206
+ return crypto.createHash('sha256').update(json).digest('hex').substring(0, 16);
207
+ }
208
+
209
+ // Read from the index row, without building the resource: what callers check while
210
+ // looking through the cache for the one they want (see TerminologyWorker
211
+ // findInAdditionalResources, listVersions, loadSupplements; ExpansionCache.computeKey)
212
+ const INDEX_PROPERTIES = new Set(['url', 'version', 'vurl', 'versionedUrl', 'resourceType', 'fhirType',
213
+ 'supplementsUrl', 'isCached', 'isGlobalSupplement', 'expansionKeyHash']);
214
+
215
+ /**
216
+ * A stand-in for a cached CodeSystem / ValueSet / ConceptMap that is only built
217
+ * when it is used. It is an instance of the wrapper class (instanceof works), and
218
+ * answers url, version, resourceType and the like from the index row. Touching
219
+ * anything else - jsonObj, a method, any other property - loads the JSON and builds
220
+ * the real wrapper, once; from then on everything goes to that wrapper, and methods
221
+ * run on it directly (bound), so a resource that is used costs the same as one
222
+ * built eagerly, and one that isn't costs nothing.
223
+ *
224
+ * @param {Function} Cls - the wrapper class
225
+ * @param {Object} row - index row: key, resource_type, url, version, supplements, global_supplement, hash
226
+ * @param {Function} load - () => the real wrapper (reads the JSON)
227
+ */
228
+ function lazyResource(Cls, row, load) {
229
+ let real = null;
230
+ const bound = new Map();
231
+ const target = Object.create(Cls.prototype);
232
+ const url = row.url === null ? undefined : row.url;
233
+ const version = row.version === null ? undefined : row.version;
234
+ const index = {
235
+ url,
236
+ version,
237
+ vurl: version ? url + '|' + version : url,
238
+ versionedUrl: version ? url + '|' + version : url,
239
+ resourceType: row.resource_type,
240
+ fhirType: row.resource_type,
241
+ supplementsUrl: row.supplements === null ? undefined : row.supplements,
242
+ isCached: true,
243
+ // the expansion cache key's hash of this resource (see contentHash): known from the row
244
+ expansionKeyHash: () => row.hash,
245
+ // CodeSystem.isGlobalSupplement(), worked out when it was stored (null: not known)
246
+ isGlobalSupplement: row.global_supplement === null || row.global_supplement === undefined
247
+ ? undefined : () => row.global_supplement === 1
248
+ };
249
+ const materialise = () => {
250
+ if (!real) {
251
+ real = load();
252
+ }
253
+ return real;
254
+ };
255
+ return new Proxy(target, {
256
+ get(t, prop) {
257
+ if (!real && INDEX_PROPERTIES.has(prop) && index[prop] !== undefined) {
258
+ return index[prop];
259
+ }
260
+ if (!real && INDEX_PROPERTIES.has(prop) && prop !== 'isGlobalSupplement') {
261
+ return undefined;
262
+ }
263
+ const r = materialise();
264
+ const v = Reflect.get(r, prop, r);
265
+ if (typeof v === 'function' && prop !== 'constructor') {
266
+ let b = bound.get(prop);
267
+ if (!b || b.fn !== v) {
268
+ b = { fn: v, bound: v.bind(r) };
269
+ bound.set(prop, b);
270
+ }
271
+ return b.bound;
272
+ }
273
+ return v;
274
+ },
275
+ set(t, prop, value) {
276
+ return Reflect.set(materialise(), prop, value);
277
+ },
278
+ has(t, prop) {
279
+ return Reflect.has(materialise(), prop);
280
+ },
281
+ deleteProperty(t, prop) {
282
+ return Reflect.deleteProperty(materialise(), prop);
283
+ },
284
+ defineProperty(t, prop, desc) {
285
+ return Reflect.defineProperty(materialise(), prop, desc);
286
+ },
287
+ ownKeys() {
288
+ return Reflect.ownKeys(materialise());
289
+ },
290
+ getOwnPropertyDescriptor(t, prop) {
291
+ const d = Reflect.getOwnPropertyDescriptor(materialise(), prop);
292
+ if (d) {
293
+ d.configurable = true; // a proxy may not report a non-configurable property its target lacks
294
+ }
295
+ return d;
296
+ }
297
+ });
298
+ }
299
+
300
+ function markCached(resource) {
301
+ if (resource && typeof resource === 'object') {
302
+ // the same non-enumerable marker the memory store sets (see ResourceCache.add)
303
+ Object.defineProperty(resource, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
304
+ }
305
+ }
306
+
307
+ class SqliteResourceCache extends ResourceCache {
308
+ /**
309
+ * @param {Object} stats - server stats (may be null)
310
+ * @param {Object} options
311
+ * @param {string} options.path - the SQLite file (local disk only)
312
+ * @param {string} [options.scope=''] - which endpoint's caches these are; endpoints
313
+ * share the file but not caches
314
+ * @param {number} [options.maxTombstones=1000]
315
+ * @param {number} [options.maxBytes=0] - size cap for the whole file, 0 = none
316
+ * @param {number} [options.touchIntervalMs=30000] - least time between last_used refreshes
317
+ * @param {number} [options.busyTimeoutMs=5000]
318
+ * @param {string|null} [options.generation] - the server lifecycle (tx.js): the master
319
+ * passes a new token each time it starts, which moves every live cache to a "lost to
320
+ * a server restart" tombstone and begins that generation; a tx-worker passes null and
321
+ * joins the current one. Omit for a store that keeps caches across opens and accepts
322
+ * any id (tests).
323
+ */
324
+ constructor(stats, options = {}) {
325
+ super(stats, options.maxTombstones || 1000);
326
+ this.maxBytes = options.maxBytes > 0 ? options.maxBytes : 0;
327
+ if (!options.path) {
328
+ throw new Error('SqliteResourceCache needs a path');
329
+ }
330
+ this.path = path.resolve(options.path);
331
+ this.scope = options.scope || '';
332
+ this.touchIntervalMs = options.touchIntervalMs >= 0 ? options.touchIntervalMs : DEFAULT_TOUCH_INTERVAL_MS;
333
+ this.managedLifecycle = Object.hasOwn(options, 'generation');
334
+ if (this.managedLifecycle && options.generation !== null &&
335
+ (typeof options.generation !== 'string' || !TOKEN.test(options.generation))) {
336
+ throw new Error('Invalid internal resource-cache generation');
337
+ }
338
+ this.db = openDatabase(this.path, options.busyTimeoutMs || DEFAULT_BUSY_TIMEOUT_MS);
339
+ this.closed = false;
340
+ this._prepare();
341
+ try {
342
+ if (this.managedLifecycle) this._initializeGeneration(options.generation);
343
+ } catch (error) {
344
+ this.close();
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ _prepare() {
350
+ const db = this.db;
351
+ this.q = {
352
+ generation: db.prepare("SELECT value FROM meta WHERE key = 'generation'"),
353
+ pastGeneration: db.prepare('SELECT * FROM generations WHERE generation = ? AND ended IS NOT NULL'),
354
+ session: db.prepare('SELECT * FROM sessions WHERE scope = ? AND cache_id = ?'),
355
+ sessionExists: db.prepare('SELECT 1 FROM sessions WHERE scope = ? AND cache_id = ?'),
356
+ sealed: db.prepare('SELECT sealed FROM sessions WHERE scope = ? AND cache_id = ?'),
357
+ resources: db.prepare('SELECT key, resource_type, fhir_version, kind, hash, json FROM resources WHERE scope = ? AND cache_id = ? ORDER BY id'),
358
+ // the index: everything but the content (and the content only of what isn't a wrapper)
359
+ index: db.prepare(`SELECT key, resource_type, url, version, supplements, global_supplement, fhir_version, kind, hash,
360
+ CASE WHEN kind = 'wrapper' THEN NULL ELSE json END AS json
361
+ FROM resources WHERE scope = ? AND cache_id = ? ORDER BY id`),
362
+ content: db.prepare('SELECT json, fhir_version, hash FROM resources WHERE scope = ? AND cache_id = ? AND key = ?'),
363
+ resourceHash: db.prepare('SELECT hash, concepts FROM resources WHERE scope = ? AND cache_id = ? AND key = ?'),
364
+ touch: db.prepare('UPDATE sessions SET last_used = ? WHERE scope = ? AND cache_id = ? AND last_used < ?'),
365
+ insertSession: db.prepare('INSERT INTO sessions (scope, cache_id, created_at, last_used, sealed) VALUES (?, ?, ?, ?, ?)'),
366
+ upsertResource: db.prepare(`
367
+ INSERT INTO resources (scope, cache_id, key, resource_type, url, version, supplements, global_supplement, fhir_version, kind, hash, json, bytes, concepts)
368
+ VALUES (@scope, @cacheId, @key, @resourceType, @url, @version, @supplements, @globalSupplement, @fhirVersion, @kind, @hash, @json, @bytes, @concepts)
369
+ ON CONFLICT (scope, cache_id, key) DO UPDATE SET
370
+ resource_type = excluded.resource_type, url = excluded.url, version = excluded.version, supplements = excluded.supplements,
371
+ global_supplement = excluded.global_supplement,
372
+ fhir_version = excluded.fhir_version, kind = excluded.kind, hash = excluded.hash,
373
+ json = excluded.json, bytes = excluded.bytes, concepts = excluded.concepts`),
374
+ totals: db.prepare(`
375
+ UPDATE sessions SET
376
+ concepts = (SELECT COALESCE(SUM(concepts), 0) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
377
+ bytes = (SELECT COALESCE(SUM(bytes), 0) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
378
+ resources = (SELECT COUNT(*) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
379
+ last_used = ?, version = version + 1
380
+ WHERE scope = ? AND cache_id = ?`),
381
+ deleteResources: db.prepare('DELETE FROM resources WHERE scope = ? AND cache_id = ?'),
382
+ deleteSession: db.prepare('DELETE FROM sessions WHERE scope = ? AND cache_id = ?'),
383
+ tombstone: db.prepare('SELECT * FROM tombstones WHERE scope = ? AND cache_id = ?'),
384
+ insertTombstone: db.prepare(`
385
+ INSERT OR REPLACE INTO tombstones (scope, cache_id, reason, at, created_at, last_used, idle_ms, max_age_ms, max_bytes)
386
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
387
+ deleteTombstone: db.prepare('DELETE FROM tombstones WHERE scope = ? AND cache_id = ?'),
388
+ trimTombstones: db.prepare(`
389
+ DELETE FROM tombstones WHERE scope = ? AND rowid NOT IN
390
+ (SELECT rowid FROM tombstones WHERE scope = ? ORDER BY rowid DESC LIMIT ?)`),
391
+ tombstoneCount: db.prepare('SELECT COUNT(*) AS n FROM tombstones WHERE scope = ?'),
392
+ expired: db.prepare('SELECT * FROM sessions WHERE scope = ? AND last_used < ?'),
393
+ allSessions: db.prepare('SELECT * FROM sessions WHERE scope = ?'),
394
+ scopeTotals: db.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(concepts), 0) AS concepts, COALESCE(SUM(bytes), 0) AS bytes FROM sessions WHERE scope = ?'),
395
+ fileBytes: db.prepare('SELECT COALESCE(SUM(bytes), 0) AS bytes FROM sessions'),
396
+ lru: db.prepare('SELECT * FROM sessions WHERE NOT (scope = ? AND cache_id = ?) ORDER BY last_used, created_at LIMIT 16'),
397
+ // the concept caps (#350) are per endpoint, as in the memory store
398
+ lruScope: db.prepare('SELECT * FROM sessions WHERE scope = ? AND cache_id <> ? ORDER BY last_used, created_at LIMIT 16')
399
+ };
400
+ }
401
+
402
+ _initializeGeneration(generation) {
403
+ this.lostToRestart = 0;
404
+ this.db.transaction(() => {
405
+ const current = this.q.generation.get()?.value;
406
+ if (generation === null) {
407
+ if (!current) throw new Error('Start the master before workers using the shared resource cache');
408
+ return;
409
+ }
410
+ if (current === generation) {
411
+ return; // another endpoint of this same master: the first one did it
412
+ }
413
+ // The master is starting: no cache, in any endpoint, is carried over
414
+ const now = Date.now();
415
+ this.lostToRestart = this.db.prepare('SELECT COUNT(*) AS n FROM sessions').get().n;
416
+ this.db.prepare(`
417
+ INSERT OR REPLACE INTO tombstones (scope, cache_id, reason, at, created_at, last_used)
418
+ SELECT scope, cache_id, 'restart', ?, created_at, last_used FROM sessions`).run(now);
419
+ this.db.exec('DELETE FROM resources; DELETE FROM sessions;');
420
+ for (const { scope } of this.db.prepare('SELECT DISTINCT scope FROM tombstones').all()) {
421
+ this.q.trimTombstones.run(scope, scope, this.maxTombstones);
422
+ }
423
+ if (current) {
424
+ this.db.prepare('INSERT OR IGNORE INTO generations (generation, started) VALUES (?, ?)').run(current, now);
425
+ this.db.prepare('UPDATE generations SET ended = ? WHERE generation = ?').run(now, current);
426
+ }
427
+ this.db.prepare('INSERT OR REPLACE INTO generations (generation, started) VALUES (?, ?)').run(generation, now);
428
+ this.db.prepare(`DELETE FROM generations WHERE rowid NOT IN
429
+ (SELECT rowid FROM generations ORDER BY rowid DESC LIMIT ?)`).run(MAX_PAST_GENERATIONS + 1);
430
+ this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('generation', ?)").run(generation);
431
+ }).immediate();
432
+ }
433
+
434
+ newCacheId() {
435
+ if (!this.managedLifecycle) return super.newCacheId();
436
+ // Read the current token each time: surviving manual workers join a replacement
437
+ // master's lifecycle without restarting. Minting before a rotation is safe;
438
+ // set() checks the token again inside its write transaction.
439
+ const generation = this.q.generation.get().value;
440
+ const id = `${generation}.${newCacheGeneration()}`;
441
+ return this.instanceCode ? `${this.instanceCode}.${id}` : id;
442
+ }
443
+
444
+ /**
445
+ * The generation in a cache-id this store issued ("[<code>.]<generation>.<random>"), or
446
+ * null if it isn't in that form. Tokens have no '.', so the id splits into its parts.
447
+ */
448
+ static generationOf(cacheId) {
449
+ if (typeof cacheId !== 'string') return null;
450
+ const parts = cacheId.split('.');
451
+ if (parts.length < 2 || parts.length > 3) return null;
452
+ const [generation, random] = parts.slice(-2);
453
+ return TOKEN.test(generation) && TOKEN.test(random) ? generation : null;
454
+ }
455
+
456
+ _isCurrentGeneration(cacheId) {
457
+ if (!this.managedLifecycle) return true;
458
+ const generation = SqliteResourceCache.generationOf(cacheId);
459
+ return generation !== null && generation === this.q.generation.get()?.value;
460
+ }
461
+
462
+ _assertGeneration(cacheId) {
463
+ if (!this._isCurrentGeneration(cacheId)) {
464
+ // has() said it was here: the master restarted while this request was using it
465
+ const generation = SqliteResourceCache.generationOf(cacheId);
466
+ const past = generation ? this.q.pastGeneration.get(generation) : null;
467
+ throw cacheIssue('error', 'not-found', 'CACHE_ID_LOST_TO_RESTART',
468
+ [cacheId, formatDuration(past ? Date.now() - past.ended : null)], 'cache-id-unknown', 404);
469
+ }
470
+ }
471
+
472
+ // ---------- reads ----------
473
+
474
+ status(cacheId) {
475
+ if (!this._isCurrentGeneration(cacheId)) return { exists: false };
476
+ const s = this.q.session.get(this.scope, cacheId);
477
+ if (!s) {
478
+ return { exists: false };
479
+ }
480
+ const now = Date.now();
481
+ return {
482
+ exists: true,
483
+ sealed: !!s.sealed,
484
+ resources: s.resources,
485
+ concepts: s.concepts || 0,
486
+ idleMs: Math.max(0, now - s.last_used),
487
+ ageMs: s.created_at ? now - s.created_at : null,
488
+ timeoutMs: this.idleTimeoutMs
489
+ };
490
+ }
491
+
492
+ has(cacheId) {
493
+ return this._isCurrentGeneration(cacheId) && !!this.q.sessionExists.get(this.scope, cacheId);
494
+ }
495
+
496
+ isSealed(cacheId) {
497
+ if (!this._isCurrentGeneration(cacheId)) return false;
498
+ const s = this.q.sealed.get(this.scope, cacheId);
499
+ return s ? !!s.sealed : false;
500
+ }
501
+
502
+ /**
503
+ * The resources held under a cache-id: a fresh set of objects every call, nothing
504
+ * shared with other requests. Wrappers are stand-ins that are only built when the
505
+ * request uses them (see lazyResource). The session row and the index are read in
506
+ * one read transaction. Later lazy reads check the indexed content hash: if another
507
+ * process replaced a resource meanwhile, this request fails so the client can retry
508
+ * from a fresh index, rather than mixing an old expansion key with new content.
509
+ */
510
+ get(cacheId) {
511
+ let session = null;
512
+ let rows = [];
513
+ this.db.transaction(() => {
514
+ this._assertGeneration(cacheId);
515
+ session = this.q.session.get(this.scope, cacheId);
516
+ if (session) {
517
+ rows = this.q.index.all(this.scope, cacheId);
518
+ }
519
+ }).deferred();
520
+ if (!session) {
521
+ if (this.logCacheOps) {
522
+ this.log.info(`cache-id '${cacheId}': miss (no entry)`);
523
+ }
524
+ return [];
525
+ }
526
+ this._maybeTouch(cacheId, session.last_used);
527
+ const result = rows.map(r => this._hydrate({ ...r, cacheId }));
528
+ if (this.logCacheOps) {
529
+ this.log.info(`cache-id '${cacheId}': hit, returning ${result.length} resource(s): ${rows.map(r => r.key).join(', ')}`);
530
+ }
531
+ return result;
532
+ }
533
+
534
+ /**
535
+ * Rebuild one stored resource. Wrappers (CodeSystem / ValueSet / ConceptMap) are
536
+ * stored in their internal R5 form, so they are rebuilt as R5 and then given back
537
+ * the FHIR version they arrived in.
538
+ */
539
+ _hydrate(row) {
540
+ if (row.kind === 'wrapper' && row.json === null) {
541
+ const Cls = wrapperClass(row.resource_type);
542
+ if (Cls) {
543
+ const cacheId = row.cacheId;
544
+ return lazyResource(Cls, row, () => this._loadWrapper(Cls, cacheId, row));
545
+ }
546
+ }
547
+ return this._build(row);
548
+ }
549
+
550
+ /**
551
+ * Build the real wrapper behind a stand-in. If the resource is no longer there
552
+ * (the client ended the cache, or it expired or was evicted, while this request
553
+ * was using it), the request fails the way a request on a dead cache-id does.
554
+ */
555
+ _loadWrapper(Cls, cacheId, row) {
556
+ this._assertGeneration(cacheId);
557
+ const c = this.q.content.get(this.scope, cacheId, row.key);
558
+ if (!c) {
559
+ const t = this.tombstone(cacheId);
560
+ throw cacheIssue('error', 'not-found', 'CACHE_ID_GONE_DURING_REQUEST',
561
+ [cacheId, t ? t.reason : 'gone', row.key.replace(/\|/g, ' ')], 'cache-id-unknown', 404);
562
+ }
563
+ if (c.hash !== row.hash || c.fhir_version !== row.fhir_version) {
564
+ // cannot-determine: an answer from a mix of the old and new contents could be wrong
565
+ throw cacheIssue('error', 'conflict', 'CACHE_ID_CHANGED_DURING_REQUEST',
566
+ [cacheId, row.key.replace(/\|/g, ' ')], 'cannot-determine', 409);
567
+ }
568
+ return this._build({ kind: 'wrapper', resource_type: row.resource_type, fhir_version: c.fhir_version, json: c.json, hash: c.hash });
569
+ }
570
+
571
+ _build(row) {
572
+ const json = JSON.parse(row.json);
573
+ let resource = json;
574
+ if (row.kind === 'wrapper') {
575
+ const Cls = wrapperClass(row.resource_type);
576
+ if (Cls) {
577
+ resource = new Cls(json, 'R5');
578
+ resource.fhirVersion = row.fhir_version || 'R5';
579
+ if (row.hash && typeof resource.setExpansionKeyHash === 'function') {
580
+ // the hash of the JSON this was stored as, which is what the memory store's
581
+ // resource (the one the client sent) hashes to
582
+ resource.setExpansionKeyHash(row.hash);
583
+ }
584
+ }
585
+ }
586
+ markCached(resource);
587
+ return resource;
588
+ }
589
+
590
+ /**
591
+ * As for the memory store, except:
592
+ * - a cache the master's start moved to a tombstone, or an id from an earlier
593
+ * generation, is CACHE_ID_LOST_TO_RESTART.
594
+ * - any other id with no record is CACHE_ID_UNKNOWN_SHARED (never issued), whatever its
595
+ * prefix: every process sees every cache, so neither a restart of one process nor a
596
+ * request routed to the wrong one can be the reason.
597
+ * - an id dropped by the size cap (maxBytes) is CACHE_ID_EVICTED_SIZE.
598
+ */
599
+ describeMissing(cacheId) {
600
+ const t = this.tombstone(cacheId);
601
+ if (!t) {
602
+ const generation = SqliteResourceCache.generationOf(cacheId);
603
+ const past = generation ? this.q.pastGeneration.get(generation) : null;
604
+ if (past) {
605
+ return { messageId: 'CACHE_ID_LOST_TO_RESTART', params: [cacheId, formatDuration(Date.now() - past.ended)] };
606
+ }
607
+ return { messageId: 'CACHE_ID_UNKNOWN_SHARED', params: [cacheId] };
608
+ }
609
+ if (t.reason === 'restart') {
610
+ return { messageId: 'CACHE_ID_LOST_TO_RESTART', params: [cacheId, formatDuration(Date.now() - t.at)] };
611
+ }
612
+ if (t.reason === 'evicted' && t.maxBytes) {
613
+ return {
614
+ messageId: 'CACHE_ID_EVICTED_SIZE',
615
+ params: [cacheId, formatDuration(Date.now() - t.at), formatBytes(t.maxBytes), formatDuration(t.idleMs)]
616
+ };
617
+ }
618
+ return super.describeMissing(cacheId);
619
+ }
620
+
621
+ tombstone(cacheId) {
622
+ const t = this.q.tombstone.get(this.scope, cacheId);
623
+ if (!t) {
624
+ return null;
625
+ }
626
+ return {
627
+ reason: t.reason,
628
+ at: t.at,
629
+ createdAt: t.created_at,
630
+ lastUsed: t.last_used,
631
+ idleMs: t.idle_ms,
632
+ maxAgeMs: t.max_age_ms,
633
+ maxBytes: t.max_bytes,
634
+ // the tombstones table has no column for it: a discarded cache went over this
635
+ // process's cacheMaxConcepts, which is set by configuration for the whole run
636
+ limit: t.reason === 'discarded' ? this.maxConceptsPerCache : undefined
637
+ };
638
+ }
639
+
640
+ tombstoneCount() {
641
+ return this.q.tombstoneCount.get(this.scope).n;
642
+ }
643
+
644
+ size() {
645
+ return this.q.scopeTotals.get(this.scope).n;
646
+ }
647
+
648
+ conceptCount() {
649
+ return this.q.scopeTotals.get(this.scope).concepts;
650
+ }
651
+
652
+ conceptCountFor(cacheId) {
653
+ if (!this._isCurrentGeneration(cacheId)) return 0;
654
+ const s = this.q.session.get(this.scope, cacheId);
655
+ return s ? (s.concepts || 0) : 0;
656
+ }
657
+
658
+ /**
659
+ * Bytes held across the whole file (every endpoint): that is what maxBytes caps.
660
+ */
661
+ byteCount() {
662
+ return this.q.fileBytes.get().bytes;
663
+ }
664
+
665
+ // High-water marks are this process's view (what it saw after its own writes)
666
+ _trackMax() {
667
+ try {
668
+ const t = this.q.scopeTotals.get(this.scope);
669
+ if (t.concepts > this.maxConcepts) {
670
+ this.maxConcepts = t.concepts;
671
+ }
672
+ if (t.n > this.maxSizeValue) {
673
+ this.maxSizeValue = t.n;
674
+ }
675
+ } catch (e) {
676
+ // statistics only
677
+ }
678
+ }
679
+
680
+ // ---------- writes ----------
681
+
682
+ /**
683
+ * Run a write transaction (BEGIN IMMEDIATE). If it fails it is tried once more;
684
+ * if that fails too, the request gets a 503 (the tx router adds Retry-After), so
685
+ * the client knows to retry rather than carrying on without its resources.
686
+ */
687
+ _write(what, fn) {
688
+ const tx = this.db.transaction(fn);
689
+ try {
690
+ return tx.immediate();
691
+ } catch (first) {
692
+ if (first instanceof Issue) throw first;
693
+ this.log.warn(`resource cache: ${what} failed (${first.code || first.message}); retrying once`);
694
+ try {
695
+ return tx.immediate();
696
+ } catch (second) {
697
+ if (second instanceof Issue) throw second;
698
+ this.log.error(`resource cache: ${what} failed twice: ${second.message}`);
699
+ throw new Issue('error', 'transient', null, 'CACHE_WRITE_FAILED',
700
+ `The server could not save to its resource cache just now (${what}: ${second.code || second.message}). Nothing was changed; try the request again`,
701
+ null, 503);
702
+ }
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Refresh last_used, at most once per touchIntervalMs: a cache in steady use
708
+ * then costs reads, not a write per request. A failed refresh is ignored - the
709
+ * worst it can do is let a cache look idle up to one interval sooner.
710
+ */
711
+ _maybeTouch(cacheId, lastUsed) {
712
+ const now = Date.now();
713
+ if (now - lastUsed < this.touchIntervalMs) {
714
+ return;
715
+ }
716
+ try {
717
+ this.db.transaction(() => {
718
+ this.q.touch.run(now, this.scope, cacheId, now - this.touchIntervalMs);
719
+ }).immediate();
720
+ } catch (e) {
721
+ // ignored, see above
722
+ }
723
+ }
724
+
725
+ touch(cacheId) {
726
+ if (!this.has(cacheId)) {
727
+ return false;
728
+ }
729
+ try {
730
+ // an explicit keepalive ($cache-control?mode=check) is not throttled
731
+ const now = Date.now();
732
+ this.db.transaction(() => {
733
+ this.q.touch.run(now, this.scope, cacheId, now);
734
+ }).immediate();
735
+ } catch (e) {
736
+ // a failed refresh is ignored: the cache is still there
737
+ }
738
+ if (this.logCacheOps) {
739
+ this.log.info(`cache-id '${cacheId}': touched (idle clock reset)`);
740
+ }
741
+ return true;
742
+ }
743
+
744
+ /**
745
+ * The row to store for a resource. Wrappers are stored as their (R5) jsonObj plus
746
+ * the FHIR version they came in as; anything else is stored as itself.
747
+ */
748
+ _row(cacheId, resource) {
749
+ const isWrapper = resource && typeof resource.jsonObj === 'object' && resource.jsonObj !== null
750
+ && !!wrapperClass(resource.resourceType);
751
+ const json = JSON.stringify(isWrapper ? resource.jsonObj : resource);
752
+ return {
753
+ scope: this.scope,
754
+ cacheId,
755
+ key: this._resourceKey(resource),
756
+ resourceType: resource.resourceType || null,
757
+ url: resource.url ?? null,
758
+ version: resource.version ?? null,
759
+ supplements: isWrapper && typeof resource.jsonObj.supplements === 'string' ? resource.jsonObj.supplements : null,
760
+ globalSupplement: isWrapper && typeof resource.isGlobalSupplement === 'function' ? this._globalSupplement(resource) : null,
761
+ fhirVersion: isWrapper ? (resource.fhirVersion || 'R5') : null,
762
+ kind: isWrapper ? 'wrapper' : 'plain',
763
+ hash: contentHash(json),
764
+ json,
765
+ bytes: Buffer.byteLength(json),
766
+ concepts: this._conceptsOf(resource)
767
+ };
768
+ }
769
+
770
+ _globalSupplement(resource) {
771
+ try {
772
+ return resource.isGlobalSupplement() ? 1 : 0;
773
+ } catch (e) {
774
+ return null; // not known: the stand-in loads the resource to ask it
775
+ }
776
+ }
777
+
778
+ add(cacheId, resources) {
779
+ this._assertGeneration(cacheId);
780
+ if (!resources || resources.length === 0) return;
781
+ resources = resources.filter(r => !!r);
782
+ for (const resource of resources) {
783
+ markCached(resource);
784
+ }
785
+ // the last copy of a key wins, as it would in the memory store
786
+ const byKey = new Map();
787
+ for (const resource of resources) {
788
+ const row = this._row(cacheId, resource);
789
+ byKey.delete(row.key);
790
+ byKey.set(row.key, row);
791
+ }
792
+ const rows = [...byKey.values()];
793
+
794
+ // As in the memory store's add (#356): a request whose own resources hold more than one
795
+ // cache may hold is refused as too costly (422), and the cache is kept
796
+ const ownConcepts = rows.reduce((sum, r) => sum + r.concepts, 0);
797
+ if (ownConcepts > this.maxConceptsPerCache) {
798
+ // (in English: the worker translates it into the request's language - see addToClientCache)
799
+ const err = new Issue('error', 'too-costly', null, 'CACHE_REQUEST_TOO_BIG',
800
+ `This request's resources hold ${ownConcepts} concepts, more than this server allows in one cache (${this.maxConceptsPerCache}, cacheMaxConcepts), so no cache can hold them`, 'too-costly', 422).handleAsOO(422);
801
+ err.msgParams = [ownConcepts, this.maxConceptsPerCache];
802
+ throw err;
803
+ }
804
+
805
+ // Most requests re-send resources the cache already has, unchanged. Check
806
+ // that with reads first, so they don't take the write lock at all.
807
+ const session = this.q.session.get(this.scope, cacheId);
808
+ if (session) {
809
+ const changed = rows.some(r => {
810
+ const existing = this.q.resourceHash.get(this.scope, cacheId, r.key);
811
+ return !existing || existing.hash !== r.hash || existing.concepts !== r.concepts;
812
+ });
813
+ if (!changed) {
814
+ this._maybeTouch(cacheId, session.last_used);
815
+ return;
816
+ }
817
+ }
818
+
819
+ let discarded = null;
820
+ this._write('add', () => {
821
+ this._assertGeneration(cacheId);
822
+ const now = Date.now();
823
+ if (!this.q.sessionExists.get(this.scope, cacheId)) {
824
+ if (this.q.tombstone.get(this.scope, cacheId)) {
825
+ // Ended or expired (by another process) between the caller's check and
826
+ // now: don't bring it back from the dead.
827
+ this.log.info(`cache-id '${cacheId}': not adding ${rows.length} resource(s), the cache is gone`);
828
+ return;
829
+ }
830
+ this.q.insertSession.run(this.scope, cacheId, now, now, 0);
831
+ }
832
+ for (const row of rows) {
833
+ this.q.upsertResource.run(row);
834
+ if (this.logCacheOps) {
835
+ this.log.info(`cache-id '${cacheId}': stored ${row.key}`);
836
+ }
837
+ }
838
+ this.q.totals.run(now, this.scope, cacheId);
839
+ // As in the memory store, an add that takes a cache over cacheMaxConcepts discards
840
+ // it (the client can't use a cache it can't add to). The burial is committed; the
841
+ // error is thrown once the transaction is done, so it doesn't roll it back.
842
+ const after = this.q.session.get(this.scope, cacheId);
843
+ if (after && (after.concepts || 0) > this.maxConceptsPerCache) {
844
+ this._bury(after, 'discarded', now);
845
+ discarded = after.concepts || 0;
846
+ return;
847
+ }
848
+ this._enforceConceptLimitsTx(cacheId, now);
849
+ this._enforceMaxBytesTx(cacheId, now);
850
+ });
851
+ if (discarded !== null) {
852
+ this.log.info(`cache-id '${cacheId}': discarded (would hold ${discarded} concepts; one cache may hold ${this.maxConceptsPerCache})`);
853
+ // As in the memory store: answered as any cache that is gone (404, tx-issue-type
854
+ // cache-id-unknown); the operation is not performed.
855
+ const err = new Issue('error', 'not-found', null, 'CACHE_ID_DISCARDED_TOO_BIG',
856
+ `The cache '${cacheId}' grew too large: this request would have taken it over ${this.maxConceptsPerCache} concepts, ` +
857
+ 'the most this server allows in one cache (cacheMaxConcepts), so the server has dropped the cache. ' +
858
+ 'This operation has not been performed. Start a new cache, and send the request again', 'cache-id-unknown', 404).handleAsOO(404);
859
+ err.msgParams = [cacheId, this.maxConceptsPerCache];
860
+ throw err;
861
+ }
862
+ this._trackMax();
863
+ }
864
+
865
+ set(cacheId, resources, sealed = false) {
866
+ resources = this._prepareSet(cacheId, resources, sealed);
867
+ const rows = resources.map(r => this._row(cacheId, r));
868
+ this._write('start', () => {
869
+ this._assertGeneration(cacheId);
870
+ this._replaceTx(cacheId, rows, sealed);
871
+ });
872
+ this._trackMax();
873
+ }
874
+
875
+ /**
876
+ * $cache-control?mode=start. The id is minted inside the write transaction, from the
877
+ * generation as it is then: a master starting between minting and writing could
878
+ * otherwise leave a new cache with an id from the generation before, which would be
879
+ * reported as lost before the client ever used it.
880
+ */
881
+ start(resources, sealed = false) {
882
+ if (!this.managedLifecycle) {
883
+ return super.start(resources, sealed);
884
+ }
885
+ resources = this._prepareSet(null, resources, sealed);
886
+ const rows = resources.map(r => this._row(null, r));
887
+ let cacheId = null;
888
+ this._write('start', () => {
889
+ cacheId = this.newCacheId();
890
+ this._replaceTx(cacheId, rows.map(row => ({ ...row, cacheId })), sealed);
891
+ });
892
+ if (this.logCacheOps) {
893
+ this.log.info(`cache-id '${cacheId}': started`);
894
+ }
895
+ this._trackMax();
896
+ return cacheId;
897
+ }
898
+
899
+ _prepareSet(cacheId, resources, sealed) {
900
+ resources = (resources || []).filter(r => !!r);
901
+ if (this.logCacheOps) {
902
+ this.log.info(`cache-id '${cacheId ?? '(new)'}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
903
+ }
904
+ for (const resource of resources) {
905
+ markCached(resource);
906
+ }
907
+ return resources;
908
+ }
909
+
910
+ /** Replace a cache's content (inside a write transaction). */
911
+ _replaceTx(cacheId, rows, sealed) {
912
+ const now = Date.now();
913
+ const existing = this.q.session.get(this.scope, cacheId);
914
+ this.q.deleteResources.run(this.scope, cacheId);
915
+ this.q.deleteSession.run(this.scope, cacheId);
916
+ this.q.insertSession.run(this.scope, cacheId, existing ? existing.created_at : now, now, sealed ? 1 : 0);
917
+ for (const row of rows) {
918
+ this.q.upsertResource.run(row);
919
+ }
920
+ this.q.totals.run(now, this.scope, cacheId);
921
+ // alive again, so any record of an earlier death is now wrong
922
+ this.q.deleteTombstone.run(this.scope, cacheId);
923
+ this._enforceConceptLimitsTx(cacheId, now);
924
+ this._enforceMaxBytesTx(cacheId, now);
925
+ }
926
+
927
+ /**
928
+ * The concept limits of the memory store (ResourceCache.setLimits, cacheMaxConcepts and
929
+ * cacheMaxTotalConcepts), inside the write transaction: a change that takes one cache over
930
+ * its limit is refused (the throw rolls the transaction back, so the cache is kept as it
931
+ * was; add() has already discarded a cache it took over the limit); over the endpoint's
932
+ * total, the least recently used caches of this endpoint are dropped whole (never the one
933
+ * just changed), and their ids then say why (CACHE_ID_EVICTED).
934
+ */
935
+ _enforceConceptLimitsTx(cacheId, now) {
936
+ const session = this.q.session.get(this.scope, cacheId);
937
+ this._checkLimit(cacheId, session ? (session.concepts || 0) : 0);
938
+ let total = this.q.scopeTotals.get(this.scope).concepts;
939
+ while (total > this.maxTotalConcepts) {
940
+ const victims = this.q.lruScope.all(this.scope, cacheId);
941
+ if (victims.length === 0) {
942
+ return;
943
+ }
944
+ for (const v of victims) {
945
+ if (total <= this.maxTotalConcepts) {
946
+ break;
947
+ }
948
+ this._bury(v, 'evicted', now, { idleMs: now - v.last_used });
949
+ total -= v.concepts || 0;
950
+ this.log.info(`cache-id '${v.cache_id}': dropped to make room (held ${v.concepts} concepts; the caches may hold ${this.maxTotalConcepts} in all)`);
951
+ }
952
+ }
953
+ }
954
+
955
+ /** Move a session to the tombstones (inside a write transaction). */
956
+ _bury(session, reason, now, extra = {}) {
957
+ this.q.insertTombstone.run(session.scope, session.cache_id, reason, now, session.created_at, session.last_used,
958
+ extra.idleMs ?? null, extra.maxAgeMs ?? null, extra.maxBytes ?? null);
959
+ this.q.deleteResources.run(session.scope, session.cache_id);
960
+ this.q.deleteSession.run(session.scope, session.cache_id);
961
+ this.q.trimTombstones.run(session.scope, session.scope, this.maxTombstones);
962
+ }
963
+
964
+ /**
965
+ * The size cap covers the whole file (all endpoints): drop the least recently
966
+ * used caches until the file is under it. Never the cache being written.
967
+ */
968
+ _enforceMaxBytesTx(keepId, now) {
969
+ if (!this.maxBytes) {
970
+ return;
971
+ }
972
+ let total = this.q.fileBytes.get().bytes;
973
+ while (total > this.maxBytes) {
974
+ const victims = this.q.lru.all(this.scope, keepId);
975
+ if (victims.length === 0) {
976
+ return;
977
+ }
978
+ for (const v of victims) {
979
+ if (total <= this.maxBytes) {
980
+ break;
981
+ }
982
+ const idleMs = now - v.last_used;
983
+ this._bury(v, 'evicted', now, { idleMs, maxBytes: this.maxBytes });
984
+ total -= v.bytes;
985
+ this.log.info(`cache-id '${v.cache_id}': evicted (cache over its ${formatBytes(this.maxBytes)} cap; idle ${formatDuration(idleMs)})`);
986
+ }
987
+ }
988
+ }
989
+
990
+ clear(cacheId) {
991
+ this._write('end', () => {
992
+ const s = this.q.session.get(this.scope, cacheId);
993
+ if (s) {
994
+ this._bury(s, 'closed', Date.now());
995
+ if (this.logCacheOps) {
996
+ this.log.info(`cache-id '${cacheId}': closed by client after ${formatDuration(Date.now() - s.created_at)} (held ${s.resources} resource(s))`);
997
+ }
998
+ }
999
+ });
1000
+ }
1001
+
1002
+ clearAll() {
1003
+ this._write('clear all', () => {
1004
+ const now = Date.now();
1005
+ for (const s of this.q.allSessions.all(this.scope)) {
1006
+ this._bury(s, 'cleared', now);
1007
+ }
1008
+ });
1009
+ }
1010
+
1011
+ /**
1012
+ * The idle sweep. One transaction: every session idle for longer than maxAge is
1013
+ * tombstoned and deleted. Idempotent, so it doesn't matter which process runs
1014
+ * it, or how many do.
1015
+ */
1016
+ prune(maxAge = 3600000) {
1017
+ if (this.stats) {
1018
+ this.stats.task('Client Cache', 'Pruning');
1019
+ }
1020
+ let n = 0;
1021
+ try {
1022
+ this.db.transaction(() => {
1023
+ const now = Date.now();
1024
+ for (const s of this.q.expired.all(this.scope, now - maxAge)) {
1025
+ const idleMs = now - s.last_used;
1026
+ this._bury(s, 'expired', now, { idleMs, maxAgeMs: maxAge });
1027
+ n++;
1028
+ if (this.logCacheOps) {
1029
+ this.log.info(`cache-id '${s.cache_id}': expired after ${formatDuration(idleMs)} idle (timeout ${formatDuration(maxAge)})`);
1030
+ }
1031
+ }
1032
+ }).immediate();
1033
+ } catch (e) {
1034
+ // the next sweep will pick them up
1035
+ this.log.error(`resource cache: idle sweep failed: ${e.message}`);
1036
+ }
1037
+ if (this.stats) {
1038
+ this.stats.taskDone('Client Cache', `Pruned ${n} of ${n + this.size()} entries`);
1039
+ }
1040
+ }
1041
+
1042
+ close() {
1043
+ if (!this.closed) {
1044
+ this.closed = true;
1045
+ releaseDatabase(this.path);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ module.exports = { SqliteResourceCache, contentHash, formatBytes };