plumb-fhir 0.6.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 (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE.txt +176 -0
  3. package/NOTICE +20 -0
  4. package/README.md +767 -0
  5. package/dist/checker.cjs +1790 -0
  6. package/dist/cjs/checker/bundle.d.ts +23 -0
  7. package/dist/cjs/checker/bundle.d.ts.map +1 -0
  8. package/dist/cjs/checker/handler.d.ts +46 -0
  9. package/dist/cjs/checker/handler.d.ts.map +1 -0
  10. package/dist/cjs/checker/input.d.ts +12 -0
  11. package/dist/cjs/checker/input.d.ts.map +1 -0
  12. package/dist/cjs/checker/install.d.ts +33 -0
  13. package/dist/cjs/checker/install.d.ts.map +1 -0
  14. package/dist/cjs/commands.d.ts +21 -0
  15. package/dist/cjs/commands.d.ts.map +1 -0
  16. package/dist/cjs/config.d.ts +159 -0
  17. package/dist/cjs/config.d.ts.map +1 -0
  18. package/dist/cjs/conformance.d.ts +89 -0
  19. package/dist/cjs/conformance.d.ts.map +1 -0
  20. package/dist/cjs/connect.d.ts +70 -0
  21. package/dist/cjs/connect.d.ts.map +1 -0
  22. package/dist/cjs/emit/print.d.ts +11 -0
  23. package/dist/cjs/emit/print.d.ts.map +1 -0
  24. package/dist/cjs/emit/routes.d.ts +34 -0
  25. package/dist/cjs/emit/routes.d.ts.map +1 -0
  26. package/dist/cjs/emit/transform.d.ts +130 -0
  27. package/dist/cjs/emit/transform.d.ts.map +1 -0
  28. package/dist/cjs/emit/valuesets.d.ts +18 -0
  29. package/dist/cjs/emit/valuesets.d.ts.map +1 -0
  30. package/dist/cjs/emit/write.d.ts +31 -0
  31. package/dist/cjs/emit/write.d.ts.map +1 -0
  32. package/dist/cjs/generate.d.ts +45 -0
  33. package/dist/cjs/generate.d.ts.map +1 -0
  34. package/dist/cjs/index.cjs +848 -0
  35. package/dist/cjs/index.cjs.map +7 -0
  36. package/dist/cjs/index.d.ts +5 -0
  37. package/dist/cjs/index.d.ts.map +1 -0
  38. package/dist/cjs/loader.d.ts +59 -0
  39. package/dist/cjs/loader.d.ts.map +1 -0
  40. package/dist/cjs/package.json +1 -0
  41. package/dist/cjs/packages.d.ts +50 -0
  42. package/dist/cjs/packages.d.ts.map +1 -0
  43. package/dist/cjs/project.d.ts +145 -0
  44. package/dist/cjs/project.d.ts.map +1 -0
  45. package/dist/cjs/push.d.ts +57 -0
  46. package/dist/cjs/push.d.ts.map +1 -0
  47. package/dist/cjs/sushi.d.ts +35 -0
  48. package/dist/cjs/sushi.d.ts.map +1 -0
  49. package/dist/cjs/tar.d.ts +7 -0
  50. package/dist/cjs/tar.d.ts.map +1 -0
  51. package/dist/cjs/validate.d.ts +22 -0
  52. package/dist/cjs/validate.d.ts.map +1 -0
  53. package/dist/esm/checker/bundle.d.ts +23 -0
  54. package/dist/esm/checker/bundle.d.ts.map +1 -0
  55. package/dist/esm/checker/handler.d.ts +46 -0
  56. package/dist/esm/checker/handler.d.ts.map +1 -0
  57. package/dist/esm/checker/input.d.ts +12 -0
  58. package/dist/esm/checker/input.d.ts.map +1 -0
  59. package/dist/esm/checker/install.d.ts +33 -0
  60. package/dist/esm/checker/install.d.ts.map +1 -0
  61. package/dist/esm/cli.mjs +4247 -0
  62. package/dist/esm/cli.mjs.map +7 -0
  63. package/dist/esm/commands.d.ts +21 -0
  64. package/dist/esm/commands.d.ts.map +1 -0
  65. package/dist/esm/config.d.ts +159 -0
  66. package/dist/esm/config.d.ts.map +1 -0
  67. package/dist/esm/conformance.d.ts +89 -0
  68. package/dist/esm/conformance.d.ts.map +1 -0
  69. package/dist/esm/connect.d.ts +70 -0
  70. package/dist/esm/connect.d.ts.map +1 -0
  71. package/dist/esm/emit/print.d.ts +11 -0
  72. package/dist/esm/emit/print.d.ts.map +1 -0
  73. package/dist/esm/emit/routes.d.ts +34 -0
  74. package/dist/esm/emit/routes.d.ts.map +1 -0
  75. package/dist/esm/emit/transform.d.ts +130 -0
  76. package/dist/esm/emit/transform.d.ts.map +1 -0
  77. package/dist/esm/emit/valuesets.d.ts +18 -0
  78. package/dist/esm/emit/valuesets.d.ts.map +1 -0
  79. package/dist/esm/emit/write.d.ts +31 -0
  80. package/dist/esm/emit/write.d.ts.map +1 -0
  81. package/dist/esm/generate.d.ts +45 -0
  82. package/dist/esm/generate.d.ts.map +1 -0
  83. package/dist/esm/index.d.ts +5 -0
  84. package/dist/esm/index.d.ts.map +1 -0
  85. package/dist/esm/index.mjs +832 -0
  86. package/dist/esm/index.mjs.map +7 -0
  87. package/dist/esm/loader.d.ts +59 -0
  88. package/dist/esm/loader.d.ts.map +1 -0
  89. package/dist/esm/package.json +1 -0
  90. package/dist/esm/packages.d.ts +50 -0
  91. package/dist/esm/packages.d.ts.map +1 -0
  92. package/dist/esm/project.d.ts +145 -0
  93. package/dist/esm/project.d.ts.map +1 -0
  94. package/dist/esm/push.d.ts +57 -0
  95. package/dist/esm/push.d.ts.map +1 -0
  96. package/dist/esm/sushi.d.ts +35 -0
  97. package/dist/esm/sushi.d.ts.map +1 -0
  98. package/dist/esm/tar.d.ts +7 -0
  99. package/dist/esm/tar.d.ts.map +1 -0
  100. package/dist/esm/validate.d.ts +22 -0
  101. package/dist/esm/validate.d.ts.map +1 -0
  102. package/package.json +79 -0
package/README.md ADDED
@@ -0,0 +1,767 @@
1
+ # Plumb
2
+
3
+ Make [Medplum](https://www.medplum.com)'s own types profile-aware.
4
+
5
+ Plumb reads a Medplum project's FHIR profiles (US Core, IPS, or the project's
6
+ own) and generates TypeScript types that narrow `@medplum/fhirtypes` to what
7
+ each profile requires. A missing required field becomes a compile error instead
8
+ of a 400 from the server, and the types still work with every Medplum SDK call
9
+ and React component.
10
+
11
+ ```ts
12
+ const p: USCorePatient = { resourceType: 'Patient', name: [{ family: 'Doe' }] };
13
+ // ^ compile error: property 'identifier' is missing
14
+ ```
15
+
16
+ A plumb line is the weighted string a builder hangs to find true vertical. A
17
+ project is *plumb* when its data is true to its profiles.
18
+
19
+ > **Status: 0.x.** Plumb works end to end, and its API may still change between
20
+ > minor releases. Its npm package is `plumb-fhir`, and its command is `plumb`.
21
+
22
+ ## Quickstart
23
+
24
+ **1. Install** Plumb as a dev dependency, next to the Medplum packages it
25
+ narrows (5.1.0 or later):
26
+
27
+ ```bash
28
+ npm install --save-dev plumb-fhir
29
+ npm install @medplum/core @medplum/definitions @medplum/fhirtypes
30
+ ```
31
+
32
+ **2. Configure** `plumb.config.ts` in the project root:
33
+
34
+ ```ts
35
+ import { defineConfig } from 'plumb-fhir';
36
+
37
+ export default defineConfig({
38
+ igs: ['hl7.fhir.us.core@9.0.0'],
39
+ profiles: [
40
+ 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
41
+ 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure',
42
+ // or every profile in an IG: 'hl7.fhir.us.core/*'
43
+ ],
44
+ out: './src/fhir/generated',
45
+ });
46
+ ```
47
+
48
+ - `igs` are FHIR packages by exact version, fetched from the FHIR package
49
+ registry (`packages.fhir.org`), never npm, into the shared cache
50
+ `~/.fhir/packages`.
51
+ - `profiles` are canonical URLs, or `name/*` for every resource profile in an
52
+ IG `igs` lists.
53
+ - `local` (optional) is a folder of your own StructureDefinition JSON.
54
+ - `bindings.maxCodes` (optional, 100 by default): a required binding whose
55
+ value set has more codes keeps its base type instead of a literal union.
56
+ - `out` is the folder Plumb generates into. Plumb owns it: it removes files for
57
+ profiles you no longer list, and refuses to touch a folder holding files it
58
+ did not write.
59
+
60
+ The config is loaded by Node itself, which strips the types, so TypeScript-only
61
+ syntax such as `enum`, relative imports without `.ts`, and `tsconfig` path
62
+ aliases are not available in it.
63
+
64
+ **3. Generate:**
65
+
66
+ ```bash
67
+ npx plumb generate
68
+ ```
69
+
70
+ ```text
71
+ plumb generate
72
+ ✔ packages 0 cached, 7 fetched 10.2s
73
+ ✔ load 2 profiles 1.2s
74
+ ✔ emit 2 types, 16 slices, 0 code lists 4ms
75
+ ✔ routes 2 rows for 2 types 1ms
76
+ ✔ write 5 written, 0 removed, 0 unchanged → src/fhir/generated 1ms
77
+ Done in 11.4s
78
+ ```
79
+
80
+ The first run fetches each IG and the dependencies it declares; after that,
81
+ `generate` is offline. Commit `plumb.lock`, which pins each package's version
82
+ and hash, and the generated folder: generated code is reviewed like any other.
83
+
84
+ **4. Use the types.** Each profile gets a type, a `…ProfileUrl` constant, and,
85
+ for its slices, builders and readers:
86
+
87
+ ```ts
88
+ import { createReference } from '@medplum/core';
89
+ import {
90
+ USCoreBloodPressure,
91
+ USCoreBloodPressureProfileUrl,
92
+ type USCorePatient,
93
+ } from './fhir/generated/index.js';
94
+
95
+ const patient: USCorePatient = {
96
+ resourceType: 'Patient',
97
+ identifier: [{ system: 'http://example.org/mrn', value: '12345' }],
98
+ name: [{ family: 'Doe', given: ['Pat'] }],
99
+ };
100
+
101
+ const mmHg = (value: number) =>
102
+ ({ value, unit: 'mm[Hg]', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' }) as const;
103
+
104
+ const bp: USCoreBloodPressure = {
105
+ resourceType: 'Observation',
106
+ meta: { profile: [USCoreBloodPressureProfileUrl] },
107
+ status: 'final',
108
+ category: [USCoreBloodPressure.vsCat({})],
109
+ code: { coding: [{ system: 'http://loinc.org', code: '85354-9' }] },
110
+ subject: createReference(patient),
111
+ effectiveDateTime: '2026-01-01T09:00:00Z',
112
+ component: [
113
+ USCoreBloodPressure.systolic({ valueQuantity: mmHg(120) }),
114
+ USCoreBloodPressure.diastolic({ valueQuantity: mmHg(80) }),
115
+ ],
116
+ };
117
+
118
+ USCoreBloodPressure.getSystolic(bp)?.valueQuantity?.value; // 120
119
+ ```
120
+
121
+ A generated type is assignable wherever its base type is, so it works with
122
+ `medplum.createResource()` and Medplum's React components as it is.
123
+
124
+ **5. Validate in tests.** Types cannot say everything: a missing required
125
+ slice, a `CodeableConcept`'s binding, an invariant. `validateProfiled` runs
126
+ Medplum's own validator against the profile, offline:
127
+
128
+ ```ts
129
+ import { validateProfiled } from 'plumb-fhir';
130
+
131
+ test('the blood pressure conforms', async () => {
132
+ const report = await validateProfiled(bp, USCoreBloodPressureProfileUrl);
133
+ expect(report.errors).toEqual([]);
134
+ });
135
+ ```
136
+
137
+ It returns `{ ok, errors, warnings }` and promises the verdict of Medplum's
138
+ validator at your installed `@medplum/core` version, not a server's.
139
+
140
+ **6. Check in CI** that the committed types are up to date:
141
+
142
+ ```bash
143
+ npx plumb generate --check
144
+ ```
145
+
146
+ It regenerates in memory, compares byte for byte, and writes nothing to the
147
+ project. It exits 0 when everything is current, 1 when it finds a problem (a
148
+ stale, missing or extra file, or a lockfile that disagrees with the config),
149
+ and 2 for a usage or config error, and names the cause of each difference.
150
+ `--json` prints the full report, and `--quiet` prints only problems.
151
+
152
+ A fresh runner fetches the IG packages first (about 390 MB for US Core 9.0.0
153
+ and its dependencies), so cache the shared package cache, keyed on
154
+ `plumb.lock`. The key changes only when a package does, and every run still
155
+ checks each cached package against the lock's hashes. In GitHub Actions:
156
+
157
+ ```yaml
158
+ - uses: actions/cache@v4
159
+ with:
160
+ path: ~/.fhir/packages
161
+ key: fhir-packages-${{ hashFiles('plumb.lock') }}
162
+ - run: npx plumb generate --check
163
+ ```
164
+
165
+ ## Your own profiles in FSH
166
+
167
+ Name the folder holding `sushi-config.yaml` in `fsh`, and `generate` runs
168
+ SUSHI first, then types what it built. Plumb uses the project's own SUSHI, so
169
+ install the version your FSH is written for:
170
+
171
+ ```bash
172
+ npm install --save-dev fsh-sushi
173
+ ```
174
+
175
+ ```ts
176
+ export default defineConfig({
177
+ igs: ['hl7.fhir.us.core@9.0.0'],
178
+ profiles: ['https://example.org/fhir/StructureDefinition/my-patient'],
179
+ fsh: '.',
180
+ out: './src/fhir/generated',
181
+ });
182
+ ```
183
+
184
+ ```text
185
+ ✔ sushi 1 StructureDefinitions, 0 ValueSets 6.8s
186
+ ```
187
+
188
+ - **Commit `fsh-generated/`** with the FSH. It is the JSON `push` loads into
189
+ Medplum, and `validateProfiled` reads it without running SUSHI.
190
+ - **`generate --check` in CI** rebuilds the FSH into a temporary folder and
191
+ fails when the committed `fsh-generated/` or the types differ from it.
192
+ - **SUSHI's errors stop `generate`**, with their FSH file and line. Its
193
+ warnings are listed under the `sushi` step.
194
+ - **Keep the versions in step:** `generate` warns when `sushi-config.yaml`
195
+ depends on a package at a version `igs` does not select, and when a
196
+ profile's pinned parent version differs from the one `igs` provides.
197
+
198
+ `fsh` replaces `local`: set one or the other.
199
+
200
+ ## Write each resource to its profile
201
+
202
+ Medplum validates a resource against the profiles in its `meta.profile`, and
203
+ nothing else. Without a stamp it applies the project's `defaultProfile` for
204
+ the type, one list per type, so a heart rate, a lab result and a smoking
205
+ status written unstamped are all held to the same Observation default. And a
206
+ stamp replaces the default: an Observation stamped by hand as a heart rate
207
+ silently loses it.
208
+
209
+ `generate` writes a routing table and the functions that use it, so each
210
+ write is held to the profile its content selects:
211
+
212
+ ```ts
213
+ import { createProfiled, route } from './fhir/generated/index.js';
214
+
215
+ route(bp); // 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure'
216
+ const saved = await createProfiled(medplum, bp); // stamped, then medplum.createResource
217
+ ```
218
+
219
+ - **`route(resource)`** is pure and offline, for tests. It keeps every
220
+ profile whose keys the resource matches, prefers a child over its parent,
221
+ and returns the one left; `undefined` when no selected profile constrains
222
+ the type. It never guesses: no match, or several unrelated ones, throws a
223
+ `RoutingError` that says what would select each candidate:
224
+
225
+ ```text
226
+ RoutingError: no profile matches this Observation.
227
+ us-core-heart-rate needs category http://terminology.hl7.org/CodeSystem/observation-category|vital-signs, code http://loinc.org|8867-4
228
+ us-core-smokingstatus needs category http://terminology.hl7.org/CodeSystem/observation-category|social-history, code http://loinc.org|72166-2
229
+ Pass { profile } to choose one, or { profile: false } to write it unprofiled.
230
+ ```
231
+
232
+ - **`createProfiled(medplum, resource)`** routes, stamps the type's
233
+ `defaultProfile` plus the routed profile, and calls `createResource`. It
234
+ never changes the object passed, and a refusal rejects before anything is
235
+ written. **`updateProfiled`** routes the new content and replaces the
236
+ stamps Plumb manages, keeping any other URL in `meta.profile`.
237
+ - **`{ profile: SomeProfileUrl }`** skips routing; the resource and the
238
+ result are typed as that profile. **`{ profile: false }`** writes no Plumb
239
+ stamp, so the server's own default applies.
240
+ - They take any client with `createResource` and `updateResource`, such as
241
+ a `MedplumClient`, so the generated code needs nothing from Plumb at run
242
+ time.
243
+
244
+ **Keys come from each profile:** every fixed or pattern value on a required
245
+ first-level element (a pinned `code`), and every required slice's
246
+ discriminator values (US Core's `vital-signs` category). They are in
247
+ `_routes.ts`, reviewed with the profile change that moved them. A profile
248
+ keyed on value-set membership needs a row in the config, and so does one
249
+ whose keys overlap another's:
250
+
251
+ ```ts
252
+ export default defineConfig({
253
+ // …igs, profiles, out
254
+ routes: {
255
+ 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-smokingstatus': {
256
+ code: [{ system: 'http://loinc.org', code: '72166-2' }],
257
+ },
258
+ // Never routed: chosen only with { profile }.
259
+ 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-adi-documentreference': false,
260
+ },
261
+ defaultProfile: {
262
+ Observation: ['https://example.org/fhir/StructureDefinition/org-observation'],
263
+ },
264
+ });
265
+ ```
266
+
267
+ - A row maps a first-level element to the codings (or, for a `code`
268
+ element, the strings) that select the profile; the resource matches when
269
+ the element holds any of them. `false` takes a profile out of routing.
270
+ - `generate`'s `routes` step warns for each pair of unrelated profiles one
271
+ resource could match, naming both, so a missing row shows up before a
272
+ write fails.
273
+ - **`defaultProfile`** has the shape of Medplum's `Project.defaultProfile`.
274
+ `createProfiled` stamps it alongside the routed profile, less any default
275
+ the routed profile derives from, since a stamp replaces the server's
276
+ default. `push` writes the same `defaultProfile` to the server (see
277
+ [Configure the project](#configure-the-project)), so the stamps
278
+ `createProfiled` writes and the defaults the server applies come from one
279
+ place.
280
+
281
+ ## Read each resource as its profile
282
+
283
+ Every Medplum read returns base R4, and a cast to the profile type claims what
284
+ nothing checked. A stamp is not proof either: a record written while the
285
+ project was loose, before the profile tightened, or read through an
286
+ AccessPolicy that hides a field can carry the stamp and still lack what its
287
+ type requires. `generate` writes reads that check before they type:
288
+
289
+ ```ts
290
+ import {
291
+ readProfiled,
292
+ searchProfiled,
293
+ USCoreBloodPressureProfileUrl,
294
+ USCorePatientProfileUrl,
295
+ } from './fhir/generated/index.js';
296
+
297
+ const patient = await readProfiled(medplum, USCorePatientProfileUrl, id);
298
+ patient.name; // HumanName[], not HumanName[] | undefined
299
+
300
+ const bps = await searchProfiled(medplum, USCoreBloodPressureProfileUrl, {
301
+ patient: `Patient/${id}`,
302
+ });
303
+ ```
304
+
305
+ - **The stamp:** `meta.profile` must hold the profile's URL, or a selected
306
+ profile deriving from it (a heart rate is a vital sign). A `url|version`
307
+ stamp does not count: Medplum validated nothing against it.
308
+ - **What the type requires:** every path the type makes required beyond
309
+ `@medplum/fhirtypes` is present, from a table in `_reads.ts` generated with
310
+ the types. Values, bindings and slices stay the validator's job.
311
+ - **`readProfiled(medplum, profile, idOrReference)`** reads by id or by
312
+ `Reference` and rejects with a `ProfileReadError` when either check fails.
313
+ - **`searchProfiled(medplum, profile, query)`** adds `_profile` to the query,
314
+ so only stamped records come back, and returns them typed. It refuses
315
+ `_elements`, `_fields`, `_summary`, `_include` and `_revinclude` before any
316
+ request: a subset is not the profile's type, and included resources are
317
+ other types. Pair it with a selective filter (`patient`, `subject`) on
318
+ large tables: a profile URL is common, so `_profile` alone narrows little.
319
+ - **`isProfiled`, `asProfiled` and `pickProfiled`** run the same checks
320
+ offline, for a resource from anywhere else: a subscription, a bot's input,
321
+ a Bundle's entries. `pickProfiled` keeps the stamped ones in a mixed list.
322
+ - They take any client with `readResource`, `readReference` and
323
+ `searchResources`, such as a `MedplumClient`.
324
+
325
+ **One failing record fails a search, and the error carries the rest.**
326
+ Dropping it would hide a blood pressure from a chart without saying so; a
327
+ caller that can show the others recovers them without a second request:
328
+
329
+ ```ts
330
+ import { ProfileReadError, type USCoreBloodPressure } from './fhir/generated/index.js';
331
+
332
+ let bps: USCoreBloodPressure[];
333
+ try {
334
+ bps = await searchProfiled(medplum, USCoreBloodPressureProfileUrl, { patient });
335
+ } catch (err) {
336
+ if (!ProfileReadError.is(err, USCoreBloodPressureProfileUrl) || err.reason !== 'missing') {
337
+ throw err;
338
+ }
339
+ bps = err.passed; // typed
340
+ showNotice(`${err.failed.length} records could not be shown`);
341
+ }
342
+ ```
343
+
344
+ ```text
345
+ ProfileReadError: Patient/123 is not a us-core-patient.
346
+ missing Patient.name
347
+ Stamped records lack required data when written while the project was loose,
348
+ when an AccessPolicy hides the field, or when the profile tightened since.
349
+ See `plumb validate --env <env>`.
350
+ ```
351
+
352
+ The error names records and paths, never values, and `passed` is not
353
+ enumerable, so loggers and error trackers that copy an error leave the
354
+ clinical data out. `reason` is `'unstamped'`, `'missing'` or `'refused'`.
355
+
356
+ **A stamp proves conformance only once `plumb validate` passes.** The read
357
+ checks presence on every call; `validate` (below) is what proves every stored
358
+ record meets its profile, values and all, and is how to find each record a
359
+ read would refuse. History is not offered typed: an old version may predate
360
+ its stamp, so read it with `medplum.readHistory` and `asProfiled` it if you
361
+ must.
362
+
363
+ ## Check stored data, then load profiles
364
+
365
+ Medplum validates a resource when it is written, and never again. Tightening a
366
+ profile, or turning strict mode on, therefore arms a failure in the next write
367
+ of every stored record that does not meet it. Plumb checks first, as a database
368
+ migration tool does before it adds a constraint: `plumb validate` counts what
369
+ would fail, and `plumb push` loads profiles only while nothing would.
370
+
371
+ **1. Name the environment** in `plumb.config.ts`. The config is committed, so
372
+ it names the environment variables that hold the credentials, never the
373
+ values:
374
+
375
+ ```ts
376
+ import { defineConfig } from 'plumb-fhir';
377
+
378
+ export default defineConfig({
379
+ igs: ['hl7.fhir.us.core@9.0.0'],
380
+ profiles: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
381
+ out: './src/fhir/generated',
382
+ environments: {
383
+ prod: {
384
+ baseUrl: 'https://api.medplum.com/',
385
+ clientId: { env: 'MEDPLUM_PROD_CLIENT_ID' },
386
+ clientSecret: { env: 'MEDPLUM_PROD_CLIENT_SECRET' },
387
+ },
388
+ },
389
+ });
390
+ ```
391
+
392
+ **2. Give Plumb a client.** Create a ClientApplication in the project for CI,
393
+ and make its project membership an admin: `push` creates Plumb's checker bot
394
+ through Medplum's admin endpoint, reads bot memberships, and writes Bot,
395
+ AccessPolicy and StructureDefinition resources. If the membership has an
396
+ AccessPolicy, it must allow those writes. The project needs bots enabled; on a
397
+ self-hosted server, `push` creates the bot on the server's default bot
398
+ runtime (`defaultBotRuntimeVersion`).
399
+
400
+ **3. Install the checker** with a first `plumb push --env prod --dry-run`
401
+ (below), then check what is stored:
402
+
403
+ ```bash
404
+ npx plumb validate --env prod
405
+ ```
406
+
407
+ ```text
408
+ plumb validate --env prod
409
+ ✔ load 1 profiles of Patient 1.9s
410
+ ✔ connect https://api.medplum.com/ (strict mode off) 320ms
411
+ ✔ checker plumb-checker 0.6.0 installed 60ms
412
+ ✔ profiles 1 selected, none shadowed 80ms
413
+ ✖ validate 1 of 1 profiles would fail 38.4s
414
+ Patient: 12400 of 12400 read, 300 of 12360 fail; 40 unstamped; silent stamps: 3 url|version
415
+ us-core-patient 12360 checked, 300 failures
416
+ Patient.identifier: Missing required property (300)
417
+ Failing ids: .plumb/validate-prod.json (gitignored)
418
+ Failed in 40.8s
419
+ ```
420
+
421
+ - **Validation runs inside the project**, in Plumb's checker bot, with the
422
+ `@medplum/core` release Plumb was built with. Patient data never leaves
423
+ Medplum: only counts, reasons and the ids of failing records come back. The
424
+ bot's own AccessPolicy reads the checked resource types and
425
+ StructureDefinition, and writes nothing.
426
+ - **Against the versions in `plumb.config.ts`,** not what the project has
427
+ loaded: it answers "what would fail if we loaded these?"
428
+ - **Each resource is checked against the selected profiles it is stamped
429
+ with** (`meta.profile`). Resources with no stamp are counted, not checked.
430
+ Silent stamps, which today validate against nothing, are counted too: a
431
+ profile URL the project does not hold, a `url|version` stamp (Medplum
432
+ matches bare URLs only) and an empty `meta.profile`.
433
+ - **Empty is never ambiguous.** Each type reports how many resources the bot
434
+ read against how many exist, so "all passed", "none carries a selected
435
+ profile", "none stored" and "0 of N readable" (an AccessPolicy that hides
436
+ them) read differently.
437
+ - **Shadowed profiles** fail the check: more than one StructureDefinition for
438
+ a selected URL, of which Medplum enforces the one whose version sorts last
439
+ as text, so `1.9.0` beats `1.10.0`.
440
+ - **Failing ids go to `.plumb/validate-<env>.json`**, never to the terminal,
441
+ CI logs or `--json`. The `.plumb` folder ignores itself in git.
442
+ - **Large projects:** the bot checks one page per run, as an async job, and
443
+ the file saves each page; `--resume` continues an interrupted run from its
444
+ last page.
445
+ - **Exit codes:** 0 when nothing fails; 1 when something would fail, a type
446
+ could not be read, or a profile is shadowed; 2 for usage, config and
447
+ connection errors, and when the checker is missing or from another Plumb
448
+ release (run `plumb push` to install it). `validate` never installs or
449
+ changes anything.
450
+
451
+ **4. Fix or migrate** the failing records, and validate again until nothing
452
+ fails.
453
+
454
+ **5. Load the profiles:**
455
+
456
+ ```bash
457
+ npx plumb push --env prod
458
+ ```
459
+
460
+ ```text
461
+ plumb push --env prod
462
+ ✔ load 1 profiles of Patient 1.9s
463
+ ✔ connect https://api.medplum.com/ (strict mode off) 320ms
464
+ ✔ checker plumb-checker 0.6.0 unchanged 90ms
465
+ ✔ plan load us-core-patient 9.0.0 (+7 dependencies) 210ms
466
+ ✔ gate nothing stored would fail 36.1s
467
+ ✔ apply 8 created, 0 updated 1.4s
468
+ ✔ recheck nothing stored fails 35.7s
469
+ Strict mode is off, and only a super admin can turn it on; every stamped resource checked passes its profiles.
470
+ Done in 76.4s
471
+ ```
472
+
473
+ - **checker** installs or updates the checker bot and its AccessPolicy, and
474
+ redeploys it only when Plumb's version or the bundle changes.
475
+ - **plan** compares the selected profiles, and the extensions and parents
476
+ they depend on, with what the project holds. A URL the project already
477
+ holds is updated in place, never added again (that would shadow it). A
478
+ StructureDefinition whose content changed without a version bump is
479
+ flagged, and a URL already shadowed stops the push. Base R4 is the server's
480
+ own and is never written.
481
+ - **gate** runs the checker against the planned versions. If any stored
482
+ resource would fail, `push` refuses and loads nothing. `--dry-run` stops
483
+ here, so it is a safe way to install the checker and preview a push.
484
+ - **apply** creates or updates the StructureDefinitions.
485
+ - **recheck** checks again at once, to catch a failing write made between the
486
+ gate and loading. A failure there fails the push (exit 1), and the profiles
487
+ stay loaded, as Postgres keeps a `NOT VALID` constraint.
488
+
489
+ Exit codes are `validate`'s, and `--json` and `--quiet` work as for `generate`.
490
+
491
+ **6. Turn strict mode on.** Plumb reports strict mode, and never sets it: only
492
+ a super admin can, so on hosted Medplum ask Medplum's team, and on a
493
+ self-hosted server its operator. With strict mode off, the server accepts a
494
+ resource that fails a profile it names; a clean `push` re-check says the
495
+ stored data is ready.
496
+
497
+ In CI, run `push` where the deploy runs, with the secrets as environment
498
+ variables:
499
+
500
+ ```yaml
501
+ - run: npx plumb push --env prod
502
+ env:
503
+ MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
504
+ MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
505
+ ```
506
+
507
+ ## Configure the project
508
+
509
+ `push` also converges the rest of what a project admin can write, from the
510
+ same config: settings, secrets, default profiles, AccessPolicies, default
511
+ access policies and client applications. None of it is set by hand in the
512
+ console any more, so it is reviewed, and a second environment is rebuilt from
513
+ the same file.
514
+
515
+ ```ts
516
+ import { defineConfig } from 'plumb-fhir';
517
+
518
+ export default defineConfig({
519
+ igs: ['hl7.fhir.us.core@9.0.0'],
520
+ profiles: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
521
+ out: './src/fhir/generated',
522
+ // Stamped by createProfiled, and now written to Project.defaultProfile too.
523
+ defaultProfile: {
524
+ Patient: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
525
+ },
526
+ project: {
527
+ settings: { supportEmail: 'support@example.org', maxUploadMb: 25 },
528
+ secrets: {
529
+ LAB_API_KEY: { env: 'LAB_API_KEY' }, // set from CI's environment
530
+ SFTP_KEY: true, // must exist; set by hand in the console
531
+ },
532
+ accessPolicies: {
533
+ clinician: {
534
+ resource: [
535
+ { resourceType: 'Patient' },
536
+ { resourceType: 'Observation' },
537
+ { resourceType: 'StructureDefinition', readonly: true },
538
+ ],
539
+ },
540
+ lab: {
541
+ resource: [{ resourceType: 'Observation' }, { resourceType: 'Patient', readonly: true }],
542
+ },
543
+ },
544
+ defaultAccessPolicies: [{ profileType: 'Practitioner', accessPolicy: 'clinician' }],
545
+ clients: { 'lab-integration': { accessPolicy: 'lab' } },
546
+ },
547
+ environments: {
548
+ prod: {
549
+ baseUrl: 'https://api.medplum.com/',
550
+ clientId: { env: 'MEDPLUM_PROD_CLIENT_ID' },
551
+ clientSecret: { env: 'MEDPLUM_PROD_CLIENT_SECRET' },
552
+ settings: { supportEmail: 'support@example.com' },
553
+ },
554
+ },
555
+ });
556
+ ```
557
+
558
+ - **Keys, not ids.** Policies and clients are named by a key, and a client or
559
+ default access policy names its policy by key. Ids differ per environment
560
+ and never appear in the config.
561
+ - **An AccessPolicy is Medplum's own shape,** less `id` and `meta`, typed from
562
+ `@medplum/fhirtypes`; its `name` is the key unless given.
563
+ - **A setting's type follows its value:** a string is `valueString`, a boolean
564
+ `valueBoolean`, a whole number `valueInteger`, any other number
565
+ `valueDecimal`. An environment's `settings` merge over `project.settings`.
566
+ - **Secrets never sit in the config.** `{ env: 'NAME' }` reads the value from
567
+ that variable and sets the secret when it differs; `true` means the secret
568
+ must already exist, and `push` never changes it. No plan, `--json` output
569
+ or error ever holds a secret's value.
570
+ - **Only a project admin's fields.** `strictMode`, `features`, `link` and
571
+ `systemSetting` are a super admin's: declaring one is a config error, and
572
+ `push` reports `strictMode` and `features` instead.
573
+
574
+ `push` runs a `project` step once the profile gate has passed, and prints its
575
+ plan, then what it wrote:
576
+
577
+ ```text
578
+ plumb push --env prod
579
+ ✔ load 1 profiles of Patient 1.9s
580
+ ✔ connect https://api.medplum.com/ (strict mode on) 320ms
581
+ ✔ checker plumb-checker 0.6.0 unchanged 90ms
582
+ ✔ plan 8 up to date, nothing to load 180ms
583
+ ✔ project plan: 3 to create, 1 to update, 0 to remove 240ms
584
+ + AccessPolicy clinician
585
+ + AccessPolicy lab
586
+ + ClientApplication lab-integration
587
+ ~ Project setting supportEmail (valueString), setting maxUploadMb (valueInteger), secret LAB_API_KEY (value changed), defaultProfile Patient, defaultAccessPolicies Practitioner
588
+ strictMode on, features: bots
589
+ ✔ project applied 4 changes 410ms
590
+ ClientApplication lab-integration created: 8c1f2b4e-5a6d-4e7f-9a0b-1c2d3e4f5a6b
591
+ Done in 3.2s
592
+ ```
593
+
594
+ - **Found again by a tag,** never by name or id. Each AccessPolicy and client
595
+ Plumb manages carries a `meta.tag` with system
596
+ `https://www.npmjs.com/package/plumb-fhir` and its key as code. A second
597
+ push with no config change plans nothing and writes nothing.
598
+ - **Untagged is untouched.** A policy or client made by hand with a key's name
599
+ stops the plan, naming it; `--adopt` tags it and converges it in place, so
600
+ an existing project comes under `push` without recreating its clients.
601
+ - **Removal needs `--prune`.** A tagged policy or client whose key left the
602
+ config is listed, and deleted only with `--prune`, a client with its
603
+ membership. Settings and secrets the config does not name are left alone,
604
+ even with `--prune`: they carry no tag.
605
+ - **A client's secret is never printed or stored.** A created client's id is
606
+ printed; read its secret in the console, and keep it in your secrets store.
607
+ - **Only this project's own resources.** A linked project's policies are never
608
+ planned, even when they carry Plumb's tag; the plan reports the links.
609
+ - **The Project is one update.** Settings, secrets, `defaultProfile` and
610
+ `defaultAccessPolicies` are read, merged and written together, after the
611
+ profiles they name are loaded. Fields `push` does not manage stay as read.
612
+ - `--dry-run` stops after the plan.
613
+
614
+ In CI, pass the variables the secrets name as well:
615
+
616
+ ```yaml
617
+ - run: npx plumb push --env prod
618
+ env:
619
+ MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
620
+ MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
621
+ LAB_API_KEY: ${{ secrets.LAB_API_KEY }}
622
+ ```
623
+
624
+ ### Catch drift
625
+
626
+ A change made by hand in the console is drift. `push --check` plans as `push`
627
+ does, with the same `--prune`, but installs nothing and writes nothing, and
628
+ exits 1 when `push` would change anything, naming what drifted:
629
+
630
+ ```text
631
+ plumb push --env prod --check
632
+ ✔ load 1 profiles of Patient 1.9s
633
+ ✔ connect https://api.medplum.com/ (strict mode on) 320ms
634
+ ✔ plan 8 up to date, nothing to load 180ms
635
+ ✔ project plan: 0 to create, 1 to update, 0 to remove 240ms
636
+ ~ AccessPolicy clinician (resource)
637
+ strictMode on, features: bots
638
+ ✖ check drift: 1 project change 0ms
639
+ Run plumb push --env prod to converge.
640
+ Failed in 2.6s
641
+ ```
642
+
643
+ Run it on a schedule:
644
+
645
+ ```yaml
646
+ name: Drift
647
+ on:
648
+ schedule:
649
+ - cron: '17 6 * * *'
650
+ jobs:
651
+ drift:
652
+ runs-on: ubuntu-latest
653
+ steps:
654
+ - uses: actions/checkout@v7
655
+ - uses: actions/setup-node@v6
656
+ with:
657
+ node-version: 22
658
+ - run: npm ci
659
+ - run: npx plumb push --env prod --check
660
+ env:
661
+ MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
662
+ MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
663
+ LAB_API_KEY: ${{ secrets.LAB_API_KEY }}
664
+ ```
665
+
666
+ ### Lock the project down
667
+
668
+ An AccessPolicy's entries are a union: an interaction is allowed if any entry
669
+ allows it, so a read-only entry does not restrict a writable `*` entry. And
670
+ `admin: true` does not bypass a policy, but a membership with no policy has
671
+ full access. So:
672
+
673
+ - **People** get `admin: false`, and a policy with no writable `*` entry. List
674
+ the clinical types they may write; give the configuration types
675
+ (ClientApplication, Bot, Subscription, OperationDefinition,
676
+ StructureDefinition, SearchParameter, AccessPolicy) read-only entries, or
677
+ none.
678
+ - **The CI client** `push` runs as gets `admin: true` and an explicit policy
679
+ that writes the configuration types.
680
+ - **A super admin** is the break-glass, and the only way to change
681
+ `strictMode` and `features`.
682
+
683
+ `push` warns, without refusing, where the config departs from this:
684
+
685
+ - a policy with a `*` entry that is not read-only;
686
+ - a client with `admin: true` and no `accessPolicy`;
687
+ - a policy other than the one `push` runs under that writes
688
+ StructureDefinition, since that bypasses the profile gate.
689
+
690
+ ## Keep `@medplum/*` in step with your server
691
+
692
+ The types and `validateProfiled` use the `@medplum/*` packages you install.
693
+ A server on a different Medplum release can validate differently, so keep the
694
+ packages on the same release as the server. Plumb supports `@medplum/*` 5.1.0
695
+ and later, TypeScript 5.0 and later (`NodeNext` or `bundler` resolution), and
696
+ Node `^22.18.0 || >=24.2.0`, the same range as Medplum. `@medplum/core`'s own
697
+ type declarations need `@types/node`, or `skipLibCheck`, as Medplum's own
698
+ projects set.
699
+
700
+ ## Known limits
701
+
702
+ The types say what TypeScript can say; `validateProfiled` and the server check
703
+ the rest. Each generated type's doc comment lists the rules it cannot check.
704
+
705
+ - **Missing or repeated required slices**, and `CodeableConcept` bindings, are
706
+ caught by `validateProfiled` and the server, not the editor. Arrays stay
707
+ plain arrays, so a slice entry built by hand is not checked against its
708
+ slice; use the generated builders.
709
+ - **Extensions are open.** Every extension slicing is open, so a known
710
+ extension with the wrong shape (a `valueString` where a `valueCode` is
711
+ required) compiles unless it is built with its helper.
712
+ - **Reference strings are not narrowed.** A `Reference<Patient>` field accepts
713
+ `{ reference: 'Group/1' }`, so that `createReference()` results stay
714
+ assignable without a cast. Medplum's validator only warns on the wrong type.
715
+ - **Value sets that cannot be listed offline** (rules, VSAC and other
716
+ terminology servers, SNOMED CT) or that have more than `bindings.maxCodes`
717
+ codes (100 by default) keep the field's base type; the doc comment names the
718
+ value set. Only a server with `validate-terminology` checks them.
719
+ - **`_field` primitive extensions** (`_birthDate` with a data-absent-reason)
720
+ are not typed: `@medplum/fhirtypes` has no `_field` properties.
721
+ - **What Medplum's parser cannot read, Plumb cannot either.** Profiles with a
722
+ `profile` slicing discriminator (IPS Bundle and DiagnosticReport) and US Core
723
+ Provenance cannot be parsed; `name/*` skips them with a warning. Slices
724
+ inside slices (IPS Composition) are parsed inconsistently, so that slicing
725
+ gets no slice types, and a warning.
726
+ - **`validateProfiled` inherits Medplum's validator:** it checks no
727
+ terminology binding, never matches an extension slice (so a profile that
728
+ requires an extension fails every resource), and does not check slice
729
+ contents, `closed` or `ordered` slicing, narrowed choice types, or rules
730
+ reached through a `contentReference`. See
731
+ [`docs/design/01-generator.md`](docs/design/01-generator.md), Testing.
732
+
733
+ - **Unstamped resources are counted, not checked.** `validate` does not
734
+ route them; `createProfiled` stamps new writes.
735
+ - **Routing keys are first-level elements,** and a key is a value that must
736
+ be present: absence (US Core Coverage's `us-core-15`) cannot select a
737
+ profile. Bundles passed to `executeBatch` are not routed; route each entry
738
+ with `route`.
739
+ - **Typed reads check presence, not values.** A stamped record whose code no
740
+ longer matches its profile reads typed; `validate` finds it.
741
+ `searchProfiled` reads one page, as `searchResources` does.
742
+ - **Plumb never sets strict mode** or `features`; a super admin does.
743
+ - **Every failure blocks `push`:** a baseline of accepted failures comes
744
+ later. Another writer can still load StructureDefinitions around `push`
745
+ unless the [lockdown](#lock-the-project-down) keeps them read-only.
746
+ - **Bots other than Plumb's checker are not declared** in `project`;
747
+ Medplum's CLI deploys them and their code.
748
+ - **The checker is tested on Medplum's `vmcontext` bot runtime.** Hosted
749
+ Medplum runs bots on AWS Lambda, which the tests cannot run.
750
+
751
+ ## Development
752
+
753
+ ```bash
754
+ npm install
755
+ npm run build
756
+ npm run typecheck
757
+ npm test
758
+ npm run lint
759
+ ```
760
+
761
+ Requires Node `^22.18.0 || >=24.2.0` and npm 11. The design is in
762
+ [`docs/spec.md`](docs/spec.md); where the project stands is in
763
+ [`docs/README.md`](docs/README.md).
764
+
765
+ ## License
766
+
767
+ [Apache-2.0](LICENSE.txt). See [`NOTICE`](NOTICE).