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.
- package/CHANGELOG.md +130 -0
- package/LICENSE.txt +176 -0
- package/NOTICE +20 -0
- package/README.md +767 -0
- package/dist/checker.cjs +1790 -0
- package/dist/cjs/checker/bundle.d.ts +23 -0
- package/dist/cjs/checker/bundle.d.ts.map +1 -0
- package/dist/cjs/checker/handler.d.ts +46 -0
- package/dist/cjs/checker/handler.d.ts.map +1 -0
- package/dist/cjs/checker/input.d.ts +12 -0
- package/dist/cjs/checker/input.d.ts.map +1 -0
- package/dist/cjs/checker/install.d.ts +33 -0
- package/dist/cjs/checker/install.d.ts.map +1 -0
- package/dist/cjs/commands.d.ts +21 -0
- package/dist/cjs/commands.d.ts.map +1 -0
- package/dist/cjs/config.d.ts +159 -0
- package/dist/cjs/config.d.ts.map +1 -0
- package/dist/cjs/conformance.d.ts +89 -0
- package/dist/cjs/conformance.d.ts.map +1 -0
- package/dist/cjs/connect.d.ts +70 -0
- package/dist/cjs/connect.d.ts.map +1 -0
- package/dist/cjs/emit/print.d.ts +11 -0
- package/dist/cjs/emit/print.d.ts.map +1 -0
- package/dist/cjs/emit/routes.d.ts +34 -0
- package/dist/cjs/emit/routes.d.ts.map +1 -0
- package/dist/cjs/emit/transform.d.ts +130 -0
- package/dist/cjs/emit/transform.d.ts.map +1 -0
- package/dist/cjs/emit/valuesets.d.ts +18 -0
- package/dist/cjs/emit/valuesets.d.ts.map +1 -0
- package/dist/cjs/emit/write.d.ts +31 -0
- package/dist/cjs/emit/write.d.ts.map +1 -0
- package/dist/cjs/generate.d.ts +45 -0
- package/dist/cjs/generate.d.ts.map +1 -0
- package/dist/cjs/index.cjs +848 -0
- package/dist/cjs/index.cjs.map +7 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/loader.d.ts +59 -0
- package/dist/cjs/loader.d.ts.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/packages.d.ts +50 -0
- package/dist/cjs/packages.d.ts.map +1 -0
- package/dist/cjs/project.d.ts +145 -0
- package/dist/cjs/project.d.ts.map +1 -0
- package/dist/cjs/push.d.ts +57 -0
- package/dist/cjs/push.d.ts.map +1 -0
- package/dist/cjs/sushi.d.ts +35 -0
- package/dist/cjs/sushi.d.ts.map +1 -0
- package/dist/cjs/tar.d.ts +7 -0
- package/dist/cjs/tar.d.ts.map +1 -0
- package/dist/cjs/validate.d.ts +22 -0
- package/dist/cjs/validate.d.ts.map +1 -0
- package/dist/esm/checker/bundle.d.ts +23 -0
- package/dist/esm/checker/bundle.d.ts.map +1 -0
- package/dist/esm/checker/handler.d.ts +46 -0
- package/dist/esm/checker/handler.d.ts.map +1 -0
- package/dist/esm/checker/input.d.ts +12 -0
- package/dist/esm/checker/input.d.ts.map +1 -0
- package/dist/esm/checker/install.d.ts +33 -0
- package/dist/esm/checker/install.d.ts.map +1 -0
- package/dist/esm/cli.mjs +4247 -0
- package/dist/esm/cli.mjs.map +7 -0
- package/dist/esm/commands.d.ts +21 -0
- package/dist/esm/commands.d.ts.map +1 -0
- package/dist/esm/config.d.ts +159 -0
- package/dist/esm/config.d.ts.map +1 -0
- package/dist/esm/conformance.d.ts +89 -0
- package/dist/esm/conformance.d.ts.map +1 -0
- package/dist/esm/connect.d.ts +70 -0
- package/dist/esm/connect.d.ts.map +1 -0
- package/dist/esm/emit/print.d.ts +11 -0
- package/dist/esm/emit/print.d.ts.map +1 -0
- package/dist/esm/emit/routes.d.ts +34 -0
- package/dist/esm/emit/routes.d.ts.map +1 -0
- package/dist/esm/emit/transform.d.ts +130 -0
- package/dist/esm/emit/transform.d.ts.map +1 -0
- package/dist/esm/emit/valuesets.d.ts +18 -0
- package/dist/esm/emit/valuesets.d.ts.map +1 -0
- package/dist/esm/emit/write.d.ts +31 -0
- package/dist/esm/emit/write.d.ts.map +1 -0
- package/dist/esm/generate.d.ts +45 -0
- package/dist/esm/generate.d.ts.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.mjs +832 -0
- package/dist/esm/index.mjs.map +7 -0
- package/dist/esm/loader.d.ts +59 -0
- package/dist/esm/loader.d.ts.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/packages.d.ts +50 -0
- package/dist/esm/packages.d.ts.map +1 -0
- package/dist/esm/project.d.ts +145 -0
- package/dist/esm/project.d.ts.map +1 -0
- package/dist/esm/push.d.ts +57 -0
- package/dist/esm/push.d.ts.map +1 -0
- package/dist/esm/sushi.d.ts +35 -0
- package/dist/esm/sushi.d.ts.map +1 -0
- package/dist/esm/tar.d.ts +7 -0
- package/dist/esm/tar.d.ts.map +1 -0
- package/dist/esm/validate.d.ts +22 -0
- package/dist/esm/validate.d.ts.map +1 -0
- 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).
|