plumb-fhir
v0.20.0
Published
Make Medplum's own types profile-aware: generate TypeScript types from FHIR profiles that narrow @medplum/fhirtypes.
Maintainers
Readme
Plumb
Make Medplum's own types profile-aware.
Plumb reads a Medplum project's FHIR profiles (US Core, IPS, or the project's
own) and generates TypeScript types that narrow @medplum/fhirtypes to what
each profile requires. A missing required field becomes a compile error instead
of a 400 from the server, and the types still work with every Medplum SDK call
and React component.
const p: USCorePatient = { resourceType: 'Patient', name: [{ family: 'Doe' }] };
// ^ compile error: property 'identifier' is missingA plumb line is the weighted string a builder hangs to find true vertical. A project is plumb when its data is true to its profiles.
Status: 0.x. Plumb works end to end, and its API may still change between minor releases. Its npm package is
plumb-fhir, and its command isplumb.
Quickstart
1. Install Plumb as a dev dependency, next to the Medplum packages it narrows (5.1.0 or later):
npm install --save-dev plumb-fhir
npm install @medplum/core @medplum/definitions @medplum/fhirtypes2. Configure plumb.config.ts in the project root:
import { defineConfig } from 'plumb-fhir';
export default defineConfig({
igs: ['[email protected]'],
profiles: [
'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
'http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure',
// or every profile in an IG: 'hl7.fhir.us.core/*'
],
out: './src/fhir/generated',
});igsare FHIR packages by exact version, fetched from the FHIR package registry (packages.fhir.org), never npm, into the shared cache~/.fhir/packages.profilesare canonical URLs, orname/*for every resource profile in an IGigslists.local(optional) is a folder of your own StructureDefinition JSON.bindings.maxCodes(optional, 100 by default): a required binding whose value set has more codes keeps its base type instead of a literal union.outis the folder Plumb generates into. Plumb owns it: it removes files for profiles you no longer list, and refuses to touch a folder holding files it did not write.
The config is loaded by Node itself, which strips the types, so TypeScript-only
syntax such as enum, relative imports without .ts, tsconfig path aliases
and packages that export TypeScript source are not available in it. When the
project has tsx installed, Plumb loads with it whatever Node cannot, and
all of these work: a workspace can import its access policies from the
package that defines them.
3. Generate:
npx plumb generateplumb generate
✔ packages 0 cached, 7 fetched 10.2s
✔ load 2 profiles 1.2s
✔ emit 2 types, 16 slices, 0 code lists 4ms
✔ routes 2 rows for 2 types 1ms
✔ write 5 written, 0 removed, 0 unchanged → src/fhir/generated 1ms
Done in 11.4sThe first run fetches each IG and the dependencies it declares; after that,
generate is offline. Commit plumb.lock, which pins each package's version
and hash, and the generated folder: generated code is reviewed like any other.
4. Use the types. Each profile gets a type, a …ProfileUrl constant, and,
for its slices, builders and readers:
import { createReference } from '@medplum/core';
import {
USCoreBloodPressure,
USCoreBloodPressureProfileUrl,
type USCorePatient,
} from './fhir/generated/index.js';
const patient: USCorePatient = {
resourceType: 'Patient',
identifier: [{ system: 'http://example.org/mrn', value: '12345' }],
name: [{ family: 'Doe', given: ['Pat'] }],
};
const mmHg = (value: number) =>
({ value, unit: 'mm[Hg]', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' }) as const;
const bp: USCoreBloodPressure = {
resourceType: 'Observation',
meta: { profile: [USCoreBloodPressureProfileUrl] },
status: 'final',
category: [USCoreBloodPressure.vsCat({})],
code: { coding: [{ system: 'http://loinc.org', code: '85354-9' }] },
subject: createReference(patient),
effectiveDateTime: '2026-01-01T09:00:00Z',
component: [
USCoreBloodPressure.systolic({ valueQuantity: mmHg(120) }),
USCoreBloodPressure.diastolic({ valueQuantity: mmHg(80) }),
],
};
USCoreBloodPressure.getSystolic(bp)?.valueQuantity?.value; // 120A generated type is assignable wherever its base type is, so it works with
medplum.createResource() and Medplum's React components as it is.
5. Validate in tests. Types cannot say everything: a missing required
slice, a CodeableConcept's binding, an invariant. validateProfiled runs
Medplum's own validator against the profile, offline:
import { validateProfiled } from 'plumb-fhir';
test('the blood pressure conforms', async () => {
const report = await validateProfiled(bp, USCoreBloodPressureProfileUrl);
expect(report.errors).toEqual([]);
});It returns { ok, errors, warnings } and promises the verdict of Medplum's
validator at your installed @medplum/core version, not a server's.
6. Check in CI that the committed types are up to date:
npx plumb generate --checkIt regenerates in memory, compares byte for byte, and writes nothing to the
project. It exits 0 when everything is current, 1 when it finds a problem (a
stale, missing or extra file, or a lockfile that disagrees with the config),
and 2 for a usage or config error, and names the cause of each difference.
--json prints the full report, and --quiet prints only problems. Each
code a report can hold, its exit code and its fix are in
docs/errors.md.
A fresh runner fetches the IG packages first (about 390 MB for US Core 9.0.0
and its dependencies), so cache the shared package cache, keyed on
plumb.lock. The key changes only when a package does, and every run still
checks each cached package against the lock's hashes. In GitHub Actions:
- uses: actions/cache@v6
with:
path: ~/.fhir/packages
key: fhir-packages-${{ hashFiles('plumb.lock') }}
- run: npx plumb generate --checkYour own profiles in FSH
Name the folder holding sushi-config.yaml in fsh, and generate runs
SUSHI first, then types what it built. Plumb uses the project's own SUSHI, so
install the version your FSH is written for:
npm install --save-dev fsh-sushiexport default defineConfig({
igs: ['[email protected]'],
profiles: ['https://example.org/fhir/StructureDefinition/my-patient'],
fsh: '.',
out: './src/fhir/generated',
});✔ sushi 1 StructureDefinitions, 0 ValueSets 6.8s- Commit
fsh-generated/resources/with the FSH. It is the JSONpushloads into Medplum, andvalidateProfiledreads it without running SUSHI. SUSHI's ownfsh-index.txtanddata/are bookkeeping no command reads: gitignore them. Marking the generated folderslinguist-generatedin.gitattributescollapses them in review diffs. generate --checkin CI rebuilds the FSH into a temporary folder and fails when the committedfsh-generated/or the types differ from it.- SUSHI's errors stop
generate, with their FSH file and line. Its warnings are listed under thesushistep. - Keep the versions in step:
generatewarns whensushi-config.yamldepends on a package at a versionigsdoes not select, and when a profile's pinned parent version differs from the oneigsprovides.
fsh replaces local: set one or the other.
Write each resource to its profile
Medplum validates a resource against the profiles in its meta.profile, and
nothing else. Without a stamp it applies the project's defaultProfile for
the type, one list per type, so a heart rate, a lab result and a smoking
status written unstamped are all held to the same Observation default. And a
stamp replaces the default: an Observation stamped by hand as a heart rate
silently loses it.
generate writes a routing table and the functions that use it, so each
write is held to the profile its content selects:
import { createProfiled, route } from './fhir/generated/index.js';
route(bp); // 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure'
const saved = await createProfiled(medplum, bp); // stamped, then medplum.createResourceroute(resource)is pure and offline, for tests. It keeps every profile whose keys the resource matches, prefers a child over its parent, and returns the one left;undefinedwhen no selected profile constrains the type. It never guesses: no match, or several unrelated ones, throws aRoutingErrorthat says what would select each candidate:RoutingError: no profile matches this Observation. us-core-heart-rate needs category http://terminology.hl7.org/CodeSystem/observation-category|vital-signs, code http://loinc.org|8867-4 us-core-smokingstatus needs category http://terminology.hl7.org/CodeSystem/observation-category|social-history, code http://loinc.org|72166-2 Pass { profile } to choose one, or { profile: false } to write it unprofiled.createProfiled(medplum, resource)routes, stamps the type'sdefaultProfileplus the routed profile, and callscreateResource. It never changes the object passed, and a refusal rejects before anything is written.updateProfiledroutes the new content and replaces the stamps Plumb manages, keeping any other URL inmeta.profile.{ profile: SomeProfileUrl }skips routing; the resource and the result are typed as that profile.{ profile: false }writes no Plumb stamp, so the server's own default applies.They take any client with
createResourceandupdateResource, such as aMedplumClient, so the generated code needs nothing from Plumb at run time.stampProfiled(resource, options?)returns the copycreateProfiledwould write, stamped and not written, for the writes it cannot make: a conditional create, an upsert, a batch or transaction entry. It takes the same options and throws the sameRoutingError:await medplum.createResourceIfNoneExist( stampProfiled(coverage, { profile: USCoreCoverageProfileUrl }), `identifier=${system}|${value}`, );Moving an edit path to
updateProfiledstamps records that were unstamped, so the server starts holding them to the profile: an edit of a stored record that does not meet it is refused from then on. Runplumb validateand fix what fails before moving edit paths.
Keys come from each profile: every fixed or pattern value on a required
first-level element (a pinned code), and every required slice's
discriminator values (US Core's vital-signs category). They are in
_routes.ts, reviewed with the profile change that moved them. A profile
keyed on value-set membership needs a row in the config, and so does one
whose keys overlap another's:
export default defineConfig({
// …igs, profiles, out
routes: {
'http://hl7.org/fhir/us/core/StructureDefinition/us-core-smokingstatus': {
code: [{ system: 'http://loinc.org', code: '72166-2' }],
},
// Never routed: chosen only with { profile }.
'http://hl7.org/fhir/us/core/StructureDefinition/us-core-adi-documentreference': false,
},
defaultProfile: {
Observation: ['https://example.org/fhir/StructureDefinition/org-observation'],
},
});- A row maps a first-level element to the codings (or, for a
codeelement, the strings) that select the profile; the resource matches when the element holds any of them.falsetakes a profile out of routing. generate'sroutesstep warns for each pair of unrelated profiles one resource could match, naming both, so a missing row shows up before a write fails.defaultProfilehas the shape of Medplum'sProject.defaultProfile.createProfiledstamps it alongside the routed profile, less any default the routed profile derives from, since a stamp replaces the server's default.pushwrites the samedefaultProfileto the server (see Configure the project), so the stampscreateProfiledwrites and the defaults the server applies come from one place.
Read each resource as its profile
Every Medplum read returns base R4, and a cast to the profile type claims what
nothing checked. A stamp is not proof either: a record written while the
project was loose, before the profile tightened, or read through an
AccessPolicy that hides a field can carry the stamp and still lack what its
type requires. generate writes reads that check before they type:
import {
readProfiled,
searchProfiled,
USCoreBloodPressureProfileUrl,
USCorePatientProfileUrl,
} from './fhir/generated/index.js';
const patient = await readProfiled(medplum, USCorePatientProfileUrl, id);
patient.name; // HumanName[], not HumanName[] | undefined
const bps = await searchProfiled(medplum, USCoreBloodPressureProfileUrl, {
patient: `Patient/${id}`,
});- The stamp:
meta.profilemust hold the profile's URL, or a selected profile deriving from it (a heart rate is a vital sign). Aurl|versionstamp does not count: Medplum validated nothing against it. - What the type requires: every path the type makes required beyond
@medplum/fhirtypesis present, from a table in_reads.tsgenerated with the types. Values, bindings and slices stay the validator's job. readProfiled(medplum, profile, idOrReference)reads by id or byReferenceand rejects with aProfileReadErrorwhen either check fails.searchProfiled(medplum, profile, query)adds_profileto the query, so only stamped records come back, and returns them typed. It refuses_elements,_fields,_summary,_includeand_revincludebefore any request: a subset is not the profile's type, and included resources are other types. Pair it with a selective filter (patient,subject) on large tables: a profile URL is common, so_profilealone narrows little.isProfiled,asProfiledandpickProfiledrun the same checks offline, for a resource from anywhere else: a subscription, a bot's input, a Bundle's entries.pickProfiledkeeps the stamped ones in a mixed list.- They take any client with
readResource,readReferenceandsearchResources, such as aMedplumClient.
One failing record fails a search, and the error carries the rest. Dropping it would hide a blood pressure from a chart without saying so; a caller that can show the others recovers them without a second request:
import { ProfileReadError, type USCoreBloodPressure } from './fhir/generated/index.js';
let bps: USCoreBloodPressure[];
try {
bps = await searchProfiled(medplum, USCoreBloodPressureProfileUrl, { patient });
} catch (err) {
if (!ProfileReadError.is(err, USCoreBloodPressureProfileUrl) || err.reason !== 'missing') {
throw err;
}
bps = err.passed; // typed
showNotice(`${err.failed.length} records could not be shown`);
}ProfileReadError: Patient/123 is not a us-core-patient.
missing Patient.name
Stamped records lack required data when written while the project was loose,
when an AccessPolicy hides the field, or when the profile tightened since.
See `plumb validate --env <env>`.The error names records and paths, never values, and passed is not
enumerable, so loggers and error trackers that copy an error leave the
clinical data out. reason is 'unstamped', 'missing' or 'refused'.
A stamp proves conformance only once plumb validate passes. The read
checks presence on every call; validate (below) is what proves every stored
record meets its profile, values and all, and is how to find each record a
read would refuse. History is not offered typed: an old version may predate
its stamp, so read it with medplum.readHistory and asProfiled it if you
must.
Find code that goes around Plumb
The profiled reads and writes only help where they are called. plumb check
reads your code the way the compiler does and reports every MedplumClient
read or write of a profiled type that does not go through them, including a
write whose type is only inferred:
export default defineConfig({
// …igs, profiles, out
check: {
tsconfig: ['apps/web/tsconfig.json'],
baseline: './plumb-check-baseline.json',
ignore: ['**/*.test.ts', '**/*.stories.tsx'],
},
});npx plumb checkplumb check
✖ check 2 new, 248 in the baseline, in 2767 files 6.2s
apps/web/src/lib/goals/goal.ts:41:10 readResource Goal → readProfiled
apps/web/src/lib/coverage/save.ts:90:5 createResource Coverage → createProfiled, updateProfiled or stampProfiled
Failed in 6.2s- What it reports:
readResource,searchResources,searchOne,searchResourcePages,createResource,updateResource,upsertResourceandcreateResourceIfNoneExiston aMedplumClient(or a class deriving from it), for a type every resource of which a selected profile holds: one with a profile that routes on no keys, or adefaultProfile. A type whose profiles are keyed on content (one code of Observation) is not reported, and neither isstringorResourceType. - A stamped write passes, through a variable too:
stampProfiledreturns its resource branded, andcheckreads the brand from the type. - A deliberate exception takes a comment with a reason on the line before:
// plumb-check: the backfill reads unstamped records to stamp them. - The baseline is a committed count per file, method and type.
checkfails only when a count grows or a new one appears, so new raw access is refused at once while the backlog shrinks.--update-baselinerecords fixes; it refuses growth unless--allow-growthis given. - Paths in the output, the baseline and
ignoreare relative to the deepest folder holding the config and every tsconfig, so a monorepo's config in one package checks code in another. - TypeScript:
checkcompiles with your project's owntypescript, which needs its compiler API: TypeScript 5 or 6. TypeScript 7's native package has none yet, andcheckexits 2 on it.
Exit codes: 0 when nothing is new, 1 when something is (or the baseline would grow, or the profiles do not load), 2 for usage, config and set-up errors.
Check stored data, then load profiles
Medplum validates a resource when it is written, and never again. Tightening a
profile, or turning strict mode on, therefore arms a failure in the next write
of every stored record that does not meet it. Plumb checks first, as a database
migration tool does before it adds a constraint: plumb validate counts what
would fail, and plumb push loads profiles only while nothing would.
1. Name the environment in plumb.config.ts. The config is committed, so
it names the environment variables that hold the credentials, never the
values:
import { defineConfig } from 'plumb-fhir';
export default defineConfig({
igs: ['[email protected]'],
profiles: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
out: './src/fhir/generated',
environments: {
prod: {
baseUrl: 'https://api.medplum.com/',
clientId: { env: 'MEDPLUM_PROD_CLIENT_ID' },
clientSecret: { env: 'MEDPLUM_PROD_CLIENT_SECRET' },
},
},
});Set the variables in the shell, or keep them in a gitignored dotenv file and
pass it with --env-file .env (repeatable; later files win, and a variable
already set wins over every file).
2. Give Plumb a client. Create a ClientApplication in the project for CI,
and make its project membership an admin: push creates Plumb's checker bot
through Medplum's admin endpoint, reads bot memberships, and writes Bot,
AccessPolicy and StructureDefinition resources. If the membership has an
AccessPolicy, it must allow those writes. The project needs bots enabled; on a
self-hosted server, push creates the bot on the server's default bot
runtime (defaultBotRuntimeVersion).
3. Install the checker with a first plumb push --env prod --dry-run
(below), then check what is stored:
npx plumb validate --env prodplumb validate --env prod
✔ load 1 profiles of Patient 1.9s
✔ connect https://api.medplum.com/ (strict mode off) 320ms
✔ checker plumb-checker 0.6.0 installed 60ms
✔ profiles 1 selected, none shadowed 80ms
✖ validate 1 of 1 profiles would fail 38.4s
Patient: 12400 of 12400 read, 300 of 12357 stamped fail; 40 unstamped; 3 stamped only with profiles not selected (--full breaks them down)
us-core-patient 12357 checked, 300 failures
Patient.identifier: Missing required property (300)
Failing ids: .plumb/validate-prod.json (gitignored)
Failed in 40.8s- Validation runs inside the project, in Plumb's checker bot, with the
@medplum/corerelease Plumb was built with. Patient data never leaves Medplum: only counts, reasons and the ids of failing records come back. The bot's own AccessPolicy reads the checked resource types and StructureDefinition, and writes nothing. - Against the versions in
plumb.config.ts, not what the project has loaded: it answers "what would fail if we loaded these?" - Each resource is checked against the selected profiles it is stamped
with (
meta.profile). The bot reads only resources carrying a selected stamp, so one narrow profile on a large type reads only what carries it. The rest are counted by query: resources with no stamp, and those stamped only with other profiles. --fullreads every resource of each type to break those other stamps down: silent stamps, which today validate against nothing (a profile URL the project does not hold, or aurl|versionstamp, since Medplum matches bare URLs only), and stamps of profiles the project holds but the config does not select. Run it when the report counts any.--unstampedforecasts what stamping would break. Before moving writes tocreateProfiledor backfilling stamps,plumb validate --env prod --unstampedalso reads each unstamped resource, routes it as the generatedroutedoes, and checks it against whatcreateProfiledwould stamp. The report addsif stamped: 40 of 2,295 routed would fail; 12 route to no profileunder each type, with the reasons. It is a forecast: the exit code still reflects stamped data only. Defaults the config does not select have no definition to check against, so only selected profiles are checked.- Empty is never ambiguous. Each type reports how many resources the bot read against how many exist, so "all passed", "none carries a selected profile", "none stored" and "0 of N readable" (an AccessPolicy that hides them) read differently.
- A type counts records; a profile counts checks. A record stamped with a
profile and its parent is checked against each, so it appears once on the
type's line (
all 331 stamped passed) and once under each profile. - Shadowed profiles fail the check: more than one StructureDefinition for
a selected URL, of which Medplum enforces the one whose version sorts last
as text, so
1.9.0beats1.10.0. - Failing ids go to
.plumb/validate-<env>.json, never to the terminal, CI logs or--json. The.plumbfolder ignores itself in git. - Large projects: the bot checks one page per run, as an async job, and
the file saves each page;
--resumecontinues an interrupted run from its last page. A type of more than one page prints its progress (Observation: 4,300 of 45,213 read). --concurrency <n>runs up to n pages at once, 1 (the default) to 16. A page is mostly round trip, the job and its polling, so n pages at once read up to n times as fast. Each type is first split by_lastUpdatedinto slices of about equal counts, about four per page in flight, and each slice pages on its own cursor; the file saves each slice's cursor, so--resumereads no page twice and skips none, with any n. A failed page stops the run once the pages in flight are saved;--resumeruns it again. Each page in flight holds one bot Lambda and polls its job once a second: keep n within the AWS account's unreserved Lambda concurrency, where a throttled page fails the run, and within the project's FHIR quota, which the CLI's polls and the bot's searches both count against. 8 is a safe start on a production Medplum.- Exit codes: 0 when nothing fails; 1 when something would fail, a type
could not be read, or a profile is shadowed; 2 for usage, config and
connection errors, and when the checker is missing or from another Plumb
release (run
plumb pushto install it).validatenever installs or changes anything.
4. Fix or migrate the failing records, and validate again until nothing fails.
5. Load the profiles:
npx plumb push --env prodplumb push --env prod
✔ load 1 profiles of Patient 1.9s
✔ connect https://api.medplum.com/ (strict mode off) 320ms
✔ checker plumb-checker 0.6.0 unchanged 90ms
✔ plan load us-core-patient 9.0.0 (+7 dependencies) 210ms
✔ gate nothing stored would fail 36.1s
✔ apply 8 created, 0 updated 1.4s
✔ recheck nothing stored fails 35.7s
Strict mode is off, and only a super admin can turn it on; every stamped resource checked passes its profiles.
Done in 76.4s- checker installs or updates the checker bot and its AccessPolicy, and redeploys it only when Plumb's version or the bundle changes.
- plan compares the selected profiles, and the extensions and parents
they depend on, with what the project holds. A URL the project already
holds is updated in place, never added again (that would shadow it). A
StructureDefinition whose content changed without a version bump is
flagged, and a URL already shadowed, or held only by a linked project,
stops the push. What the server itself holds, such as the terminology in
Medplum's base project, is left to it. The ValueSets and
CodeSystems the profiles bind are planned with them, CodeSystems first, so
a project with Medplum's
validate-terminologyfeature can resolve every binding; a CodeSystem a package ships without its codes (SNOMED CT, LOINC) is listed and left to Medplum. Base R4 is the server's own and is never written. - gate runs the checker against the planned versions. If any stored
resource would fail,
pushrefuses and loads nothing.--dry-runstops here, so it is a safe way to install the checker and preview a push. - apply creates or updates the definitions, terminology first.
- recheck checks again at once, to catch a failing write made between the
gate and loading. A failure there fails the push (exit 1), and the profiles
stay loaded, as Postgres keeps a
NOT VALIDconstraint.
Exit codes are validate's: a write that fails, or a plan blocked by an
untagged or shadowed resource, exits 1. --json and --quiet work as for
generate.
6. Turn strict mode on. Plumb reports strict mode, and never sets it: only
a super admin can, so on hosted Medplum ask Medplum's team, and on a
self-hosted server its operator. With strict mode off, the server accepts a
resource that fails a profile it names; a clean push re-check says the
stored data is ready.
In CI, run push where the deploy runs, with the secrets as environment
variables:
- run: npx plumb push --env prod
env:
MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}Migrate stored data
validate says which stored records a stricter profile would refuse; a
migration fixes them, inside the project. Together with push this is a
database's check, backfill, then tighten:
npx plumb validate --env prod # 312 Patients fail us-core-patient: birthDate missing
npx plumb migrate --env prod # dry run: 312 to change, and a forecast
npx plumb migrate --env prod --write # the backfill, resumable
npx plumb push --env prod # the gate passes; the stricter profile loadsDeclare a migration in a module the config lists. plumb migrate new
patient-birthdate scaffolds <date>-patient-birthdate.ts in the first
modules folder:
// src/migrations/20261006-patient-birthdate.ts
import { defineMigration } from '../fhir/generated/index.js';
export default defineMigration({
id: '20261006-patient-birthdate',
resourceType: 'Patient',
search: { 'birthdate:missing': 'true' }, // narrows what is read; optional
transform(patient) {
if (patient.birthDate) return undefined; // nothing to do
return [{ op: 'add', path: '/birthDate', value: '1900-01-01' }];
},
});transformis pure: it returns JSON Patch operations, orundefinedwhen the record needs nothing, so a run is safe to repeat and to resume. A transform is a function, so test it on fixtures with no server: records in, patches out.- The resource is typed by
@medplum/fhirtypes, not a profile type: the record is stale, so a profile type would claim what it lacks. searchonly narrows what is read; the runner adds_lastUpdated,_sortand_count, and reads only records last updated before the run started.- An
idstarts with a date, so branches never collide on a number.dependsOn: ['<id>']runs a migration after another; otherwise they run byid. A bad id, a cycle, an unknown type or a search parameter Medplum does not index isinvalid-migration, before anything is read.
Deploy the migrator. Migrations run a page at a time in a bot of the
project's own, so patient data never leaves Medplum. generate writes
_migrator.ts, importing each module, and the bot's entry is one line:
// src/bots/migrator.ts, built as the project's other bots are
export { handler } from '../fhir/generated/_migrator.js';// in plumb.config.ts
bots: {
migrator: { file: './dist/bots/migrator.cjs', runtime: 'awslambda', timeout: 300, policy: 'migrator' },
},
migrations: {
bot: 'migrator',
modules: ['./src/migrations/*.ts'],
restamp: true, // optional: Plumb's restamp migrations, below
},The migrator policy reads and writes the migrated types, and grants Plumb's
checker by key, { resourceType: 'Bot', bots: ['checker'] }, which forecasts
each page. push deploys the bot; a new module makes generate --check
stale until it is generated and the bot rebuilt.
Run it. A dry run is the default and writes nothing, the ledger included:
plumb migrate --env prod
✔ load 1 profiles of Patient 1.9s
✔ connect https://api.medplum.com/ 320ms
✔ migrations 1 of 1 chosen 40ms
✔ checker plumb-checker 0.18.0 installed 60ms
✔ migrator migrator current (migrator-3c07…) 70ms
✔ 20261006-patient-birthdate dry run: 312 read, 312 to change, 0 unchanged 9.8s
forecast: 312 would pass the selected profiles, 0 would still fail
Done in 12.6s- The forecast validates each changed record, as patched, against the selected profiles it is stamped with, in the checker: it answers "would these pass once the stricter profile loads?"
--writePUTs each record withIf-Matchon the version read. A record edited in between is read and transformed again; a second clash counts as a conflict, left for the next run. A record the server refuses counts as failed, with its reason. Only counts and reasons come back, never a record or an id.- Pages are 100 records (
--page-size, 20 to 1,000), each an async job, short enough for the bot's timeout. Over the project's write quota, a page waits a minute and runs again. - Ctrl-C stops after the current page, and the next
--writeresumes from its cursor.plumb migrate --env prod <id>…runs only those. - The migrator must be this build:
migrator-not-current(exit 2) untilpushdeploys it, asvalidaterequires the checker.
The ledger is one Basic per migration in the project, found by Plumb's
tag: its status (running, paused, errored or applied), the module's
hash, the git commit, the counts, and a lease, so two runs never race
(migration-running) and a run whose CLI died is taken over after ten
minutes. An applied migration is not run again. One whose module changed
since is migration-edited; --rerun <id> runs it again, changing only what
its transform still finds.
Watch every environment with plumb migrate status, which exits 1 when
anything is pending, running, paused, errored or edited, so a nightly job
catches an environment that missed a migration:
plumb migrate status --env prod
✔ migrations 2 declared
✔ connect https://api.medplum.com/
✖ status 1 applied, 1 pending
20261006-patient-birthdate: applied at 3c07a1e, 312 changed
20261020-coverage-payor: pendingOnce a migration is applied in every environment, its module can go; its
ledger entry stays, and status lists it with "(no module)".
Restamping. With restamp: true, generate also writes
plumb-restamp-<Type> for each type with routing rows. Each sets the stamps
updateProfiled would, keeps any other URL in meta.profile, and leaves a
record that routes to no single profile alone. They run again whenever
_routes.ts changes, so a new routing row or profile restamps what is
stored.
Some records are correct FHIR that the profile they route to cannot express,
such as a Coverage with no member id under us-core-15; stamped, their next
edit is refused. Declare them, and the restamps leave them alone:
// in plumb.config.ts
migrations: {
bot: 'migrator',
modules: ['./src/migrations/*.ts'],
restamp: { exclude: './src/migrations/never-stamped.ts' },
},// src/migrations/never-stamped.ts
import type { Resource } from '@medplum/fhirtypes';
/** Why a record must stay unstamped, or undefined to restamp it. */
export default function neverStamped(resource: Resource): string | undefined {
if (resource.resourceType === 'Coverage' && !resource.subscriberId && !resource.identifier) {
return 'no member id: us-core-15 cannot express it';
}
return undefined;
}The function is pure, and its reason names a kind of record, never anything
read from one: each run counts what it skipped by reason, as
3 skipped: no member id: us-core-15 cannot express it, and --json holds the
same counts under skipped and skips. The module and the files it imports
are part of the restamps' hash, so editing it makes them pending again and
generate --check stale until it is generated and the bot rebuilt. A path that
is not a file, or a module whose default export is not a function, is
invalid-restamp-exclude.
push names what is pending. The two stay separate: push never writes
patient data, and migrate never loads a profile. When the gate refuses, it
lists the pending migrations on each failing type:
✖ gate 312 stored Patients would fail us-core-patient
pending: 20261006-patient-birthdate (Patient)
Refusing to load: run plumb migrate --env prod --write, or see plumb validate --env prod.The order for a tightening is expand, migrate, contract: ship the app
writing the new shape, migrate the old records, then push the stricter
profile. A record the app writes in the old shape after a run started is not
read by it; the gate catches it, and --rerun fixes it.
Locally and in tests. On an environment marked synthetic: true (no real
patient data), --local runs the same generated runner in the CLI's process,
with no bot and no deploy; anywhere else it is not-synthetic, before
anything is read. A project's tests do the same against a
test project:
import { loadConfig } from 'plumb-fhir';
import { connectAs, migrate, testProject } from 'plumb-fhir/test';
import { expect, test } from 'vitest';
// test.seed holds Patients with no birthDate, stamped with no profile that requires one.
test('the birthdate migration backfills every patient', async () => {
const loaded = await loadConfig({ cwd: process.cwd() });
if (!loaded.ok) throw new Error('plumb.config.ts did not load');
const result = await migrate(testProject(), loaded.config, { lockPath: 'plumb.lock', write: true });
expect(result.ok).toBe(true);
const medplum = await connectAs(testProject());
expect(await medplum.searchResources('Patient', { 'birthdate:missing': 'true' })).toEqual([]);
});Configure the project
push also converges the rest of what a project admin can write, from the
same config: settings, secrets, default profiles, AccessPolicies, default
access policies and client applications. None of it is set by hand in the
console any more, so it is reviewed, and a second environment is rebuilt from
the same file.
import { defineConfig } from 'plumb-fhir';
export default defineConfig({
igs: ['[email protected]'],
profiles: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
out: './src/fhir/generated',
// Stamped by createProfiled, and now written to Project.defaultProfile too.
defaultProfile: {
Patient: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'],
},
project: {
settings: { supportEmail: '[email protected]', maxUploadMb: 25 },
secrets: {
// Set from its variable; with the variable unset, a held value is left as it is.
LAB_API_KEY: { env: 'LAB_API_KEY' },
SFTP_KEY: true, // must exist; set by hand in the console
// Only in dev: left out of every other push, removed there with --prune.
TEST_RECIPIENTS: { env: 'TEST_RECIPIENTS', environments: ['dev'] },
},
accessPolicies: {
clinician: {
resource: [
{ resourceType: 'Patient' },
{ resourceType: 'Observation' },
{ resourceType: 'StructureDefinition', readonly: true },
],
},
lab: {
resource: [{ resourceType: 'Observation' }, { resourceType: 'Patient', readonly: true }],
},
},
defaultAccessPolicies: [{ profileType: 'Practitioner', accessPolicy: 'clinician' }],
clients: { 'lab-integration': { accessPolicy: 'lab' } },
},
environments: {
prod: {
baseUrl: 'https://api.medplum.com/',
clientId: { env: 'MEDPLUM_PROD_CLIENT_ID' },
clientSecret: { env: 'MEDPLUM_PROD_CLIENT_SECRET' },
settings: { supportEmail: '[email protected]' },
},
},
});- Keys, not ids. Policies and clients are named by a key, and a client or default access policy names its policy by key. Ids differ per environment and never appear in the config.
- An AccessPolicy is Medplum's own shape, less
idandmeta, typed from@medplum/fhirtypes; itsnameis the key unless given. - A setting's type follows its value: a string is
valueString, a booleanvalueBoolean, a whole numbervalueInteger, any other numbervalueDecimal. An environment'ssettingsmerge overproject.settings. - Secrets never sit in the config.
{ env: 'NAME' }reads the value from that variable and sets the secret when it differs;truemeans the secret must already exist, andpushnever changes it. No plan,--jsonoutput or error ever holds a secret's value. - Only a project admin's fields.
strictMode,features,linkandsystemSettingare a super admin's: declaring one is a config error, andpushreportsstrictModeandfeaturesinstead.
push runs a project step once the profile gate has passed, and prints its
plan, then what it wrote:
plumb push --env prod
✔ load 1 profiles of Patient 1.9s
✔ connect https://api.medplum.com/ (strict mode on) 320ms
✔ checker plumb-checker 0.6.0 unchanged 90ms
✔ plan 8 up to date, nothing to load 180ms
✔ project plan: 3 to create, 1 to update, 0 to remove 240ms
+ AccessPolicy clinician
+ AccessPolicy lab
+ ClientApplication lab-integration
~ Project setting supportEmail (valueString), setting maxUploadMb (valueInteger), secret LAB_API_KEY (value changed), defaultProfile Patient, defaultAccessPolicies Practitioner
strictMode on, features: bots
✔ project applied 4 changes 410ms
ClientApplication lab-integration created: 8c1f2b4e-5a6d-4e7f-9a0b-1c2d3e4f5a6b
Done in 3.2s- Found again by a tag, never by name or id. Each AccessPolicy and client
Plumb manages carries a
meta.tagwith systemhttps://www.npmjs.com/package/plumb-fhirand its key as code. A second push with no config change plans nothing and writes nothing. - Untagged is untouched. A policy or client made by hand with a key's name
stops the plan, naming it;
--adopttags it and converges it in place, so an existing project comes underpushwithout recreating its clients. - Removal needs
--prune. A tagged policy or client whose key left the config is listed, and deleted only with--prune, a client with its membership. Settings and secrets the config does not name are left alone, even with--prune: they carry no tag. A secret scoped to other environments is the exception, since the config names it: one an environment holds is reported, and removed with--prune, sopush --check --prunefails while it is there. - A client's secret is never printed or stored. A created client's id is printed; read its secret in the console, and keep it in your secrets store.
- Only this project's own resources. A linked project's policies are never planned, even when they carry Plumb's tag; the plan reports the links.
- The Project is one update. Settings, secrets,
defaultProfileanddefaultAccessPoliciesare read, merged and written together, after the profiles they name are loaded. Fieldspushdoes not manage stay as read. --dry-runstops after the plan.
In CI, pass the variables the secrets name as well:
- run: npx plumb push --env prod
env:
MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
LAB_API_KEY: ${{ secrets.LAB_API_KEY }}Catch drift
A change made by hand in the console is drift. push --check plans as push
does, with the same --prune, but installs nothing and writes nothing, and
exits 1 when push would change anything, naming what drifted:
plumb push --env prod --check
✔ load 1 profiles of Patient 1.9s
✔ connect https://api.medplum.com/ (strict mode on) 320ms
✔ plan 8 up to date, nothing to load 180ms
✔ project plan: 0 to create, 1 to update, 0 to remove 240ms
~ AccessPolicy clinician (resource)
strictMode on, features: bots
✖ check drift: 1 project change 0ms
Run plumb push --env prod to converge.
Failed in 2.6sRun it on a schedule:
name: Drift
on:
schedule:
- cron: '17 6 * * *'
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npx plumb push --env prod --check
env:
MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
LAB_API_KEY: ${{ secrets.LAB_API_KEY }}Lock the project down
An AccessPolicy's entries are a union: an interaction is allowed if any entry
allows it, so a read-only entry does not restrict a writable * entry. And
admin: true does not bypass a policy, but a membership with no policy has
full access. So:
- People get
admin: false, and a policy with no writable*entry. List the clinical types they may write; give the configuration types (ClientApplication, Bot, Subscription, OperationDefinition, StructureDefinition, SearchParameter, AccessPolicy) read-only entries, or none. - The CI client
pushruns as getsadmin: trueand an explicit policy that writes the configuration types. - A super admin is the break-glass, and the only way to change
strictModeandfeatures.
push warns, without refusing, where the config departs from this:
- a policy with a
*entry that is not read-only; - a client with
admin: trueand noaccessPolicy; - a policy other than the one
pushruns under that writes StructureDefinition, since that bypasses the profile gate.
Reference content
A project depends on content that is neither a profile nor patient data: the
Questionnaires its forms render, the CodeSystems and ValueSets its codes come
from, the Organizations its resources point at. List the files, one FHIR
resource each, in content, and push converges every environment on them:
// in plumb.config.ts
content: ['./fhir/content/*.json', './fsh/fsh-generated/resources/Questionnaire-*.json'],✔ content plan: 3 to create, 1 to update, 0 to retire
+ CodeSystem http://example.org/fhir/CodeSystem/visit-reason
+ ValueSet http://example.org/fhir/ValueSet/visit-reason
+ Questionnaire http://example.org/fhir/Questionnaire/intake
~ Organization main-clinic (address)
✔ content applied 4 changes- What counts: Questionnaire, CodeSystem, ValueSet and Organization. A
FSH project points at the instances SUSHI writes, never its whole output,
which holds examples. Patient data is not content; seed data for tests is
test.seed. Medplum ignores a project's own SearchParameters, so a file of one is refused, and Subscriptions wait for the bots they trigger. - Checked before anything is written:
generateandpushrefuse a file that is not one of the four, has nourl(or, for an Organization, noid), shares one with another file, or fails Medplum's validator against base R4 or a selected profile it claims inmeta.profile. - Found again by a tag, as policies are: a Questionnaire, CodeSystem or
ValueSet by its
url, an Organization by its fileid, which is its key and never its server id. Each is written as the file states it and updated in place; a change under the sameversionis flagged. One the project already holds untagged stops the push until--adopttakes it over. - Removal retires, with
--prune: a canonical resource becomesretiredand an Organization inactive, so responses and references keep resolving. Nothing is deleted. push --checkreports content drift with the rest.
The ValueSets and CodeSystems the selected profiles bind are loaded with the
profiles, whether or not they are in content, so a project with Medplum's
validate-terminology feature resolves every binding. That feature also
stops Medplum creating bots, so push cannot install its checker in such a
project until Medplum fixes it.
Each Questionnaire in content gets a generated file. A response is never
checked against its Questionnaire on the server, so these types are the only
check its shape gets:
import { intakeAnswers, type IntakeLinkId } from './fhir/generated/index.js';
const answers = intakeAnswers(response); // throws for a response to another Questionnaire
answers.reason?.code; // 'new' | 'follow-up', from the item's answer options
answers['weight-kg']; // number | undefined
answers.allergies; // string[] | undefined: a repeating item
answers['wieght-kg']; // compile errorA choice's codes are a literal union when its answer options, or an
answerValueSet from the packages, local or content, list them offline
within bindings.maxCodes; otherwise it is any Coding. Every answer may be
missing.
Bots and Subscriptions
A project's behaviour is its bots and what triggers them. Declare both by
key, and push creates, configures and deploys them in every environment;
no server id ever appears in the repository.
// in plumb.config.ts
project: {
secrets: { SMS_API_KEY: { env: 'SMS_API_KEY' } },
accessPolicies: {
'reminder-sender': { resource: [{ resourceType: 'Appointment', readonly: true }] },
'intake-writer': { resource: [{ resourceType: 'QuestionnaireResponse' }] },
// Who may run a bot, by its key: the same policy in every environment.
'front-desk': { resource: [{ resourceType: 'Bot', bots: ['send-reminder'] }] },
},
},
bots: {
'send-reminder': {
file: './dist/bots/send-reminder.cjs', // built by the project
timeout: 30, // seconds; 10 by default
policy: 'reminder-sender', // the bot's own access
secrets: ['SMS_API_KEY'],
cron: '0 14 * * *',
},
'intake-webhook': {
file: './dist/bots/intake-webhook.cjs',
policy: 'intake-writer',
publicWebhook: true,
rawBody: true,
},
},
subscriptions: {
'new-appointment': {
criteria: 'Appointment?status=booked',
interactions: ['create'],
bot: 'send-reminder',
},
'lab-result': {
criteria: 'DiagnosticReport?status=final',
url: 'https://hooks.example.org/lab',
secret: { env: 'LAB_HOOK_SECRET' }, // signs each delivery as X-Signature
headers: { Authorization: { env: 'LAB_HOOK_TOKEN' } },
},
},✔ bots plan: 1 to create, 0 to update, 2 to deploy, 0 to disconnect
+ Bot send-reminder awslambda, policy reminder-sender
~ Bot intake-webhook (code changed (a91f… → 3c07…))
webhook intake-webhook https://api.example.org/webhook/7d1e…
✔ bots applied 2 changes
✔ subscriptions plan: 1 to create, 1 to update, 0 to turn off
+ Subscription new-appointment Appointment?status=booked → Bot send-reminder
~ Subscription lab-result (criteria changed)
✔ subscriptions applied 2 changesChecked before anything is written: an unknown policy, secret or bot key; a
publicWebhookwithout apolicy, which Medplum would answer with a 403; a schedule Medplum would silently ignore; criteria Medplum's matcher can never fire on (chained parameters,_has, a project's own SearchParameters, number, quantity and composite parameters, and modifiers it reads as equality); a FHIRPath that does not parse; and a bundle that is missing or, forvmcontext, is not CommonJS assigningexports.handler.Bots are found by identifier (Plumb's system and the key), created through Medplum's admin endpoint so each gets its membership and policy, and converged field by field. The bundle deploys only when its hash differs from the one in the deployed filename, so an unchanged bot never publishes a new Lambda version. Each public webhook's URL is printed for the environment; vendors point at it, so it never changes.
Subscriptions are found by tag, so a changed criteria updates one in place. A bot's is addressed by the bot's id in this environment. Secret and header values are read from their variables when written and never kept in the plan; Medplum cuts a header value at its first
:, so one holding a:stops the push. One Medplum turned off after failed deliveries is reported and turned back on.Removal is gentle:
--pruneclears a removed bot's schedule and turns a removed Subscription off. A bot is never deleted, since that would break every webhook pointing at it.Scoped to some environments:
environments: ['dev']on a bot keeps it, and the operations it implements, out of every other environment, so a bot still being built never runs on a schedule or answers a webhook in production. A Subscription takes its bot's scope unless it names a narrower one. Each push and--checklists what it left out in ascopestep; one an environment already holds is treated as removed. A test project runs every bot, and the migrations bot has no scope.Triggers per environment:
environmentOverrideson a bot changes its schedule or public webhook while keeping the bot deployed. Omitted fields inherit the bot's defaults;cron: nullremoves its schedule andpublicWebhook: falseturns its webhook off. For example:sync: { file: './dist/bots/sync.cjs', policy: 'sync', cron: '0 3 * * *', publicWebhook: true, environmentOverrides: { prod: { cron: null, publicWebhook: false } }, }Override keys must name declared environments, and only
cronandpublicWebhookmay be overridden. Schedules are validated as usual; enabling a webhook still requires the bot'spolicy. Push, dry-run and--checkuse the resolved triggers;_bots.tsunions the inputs from every environment where the bot is deployed, plus the base triggers used by test projects. Test projects always use base triggers.Existing bots and Subscriptions the project made by hand are adopted with
--adopt: a bot by its name, keeping its id, membership and webhook URL; a Subscription by its criteria and endpoint. Until then,pushnames them and stops.Features only a super admin can turn on: the project needs
bots, andcronwhen a bot has a schedule.pushchecks both before writing anything (bots-disabled,cron-disabled).push --checkreports bots and Subscriptions edited in the console, and code deployed by hand, as drift.
A bot that throws when a Subscription runs it counts as delivered in Medplum: it is not retried, and the Subscription stays on. Check the bot's AuditEvents, not the Subscription's status.
Operations and typed bots
A custom operation is a contract: its code, the bot that implements it, and
what goes in and comes out. Each side is a resource type, a selected profile,
or any Standard Schema value (Zod, Valibot,
ArkType and others implement it), so Plumb adds no schema library. List the
modules that hold contracts in operations, and generate writes
defineOperation, callOperation and handleOperation into the generated
folder, so neither an app nor a bot takes a runtime dependency on Plumb.
// src/operations/send-message.ts
import { z } from 'zod';
import { defineOperation } from '../fhir/generated/index.js';
export const sendMessage = defineOperation({
code: 'send-message',
level: 'type',
resource: 'Communication',
bot: 'messenger', // a key in bots
input: z.object({ to: z.string(), text: z.string().min(1) }),
output: 'Communication', // or a selected profile's URL
});// in plumb.config.ts
operations: ['./src/operations/*.ts'],The caller and the bot share the contract. Both check at run time, so a malformed message is refused before it is sent, and a handler that returns the wrong thing fails in the bot:
// in the app
const sent = await callOperation(medplum, sendMessage, { to: 'Patient/1', text: 'Hi' });
// ^? Communication
// the bot's bundle
export const handler = handleOperation(sendMessage, async (medplum: MedplumClient, input) => {
// input: { to: string; text: string }; the return must be a Communication
return medplum.createResource({ resourceType: 'Communication', status: 'completed' });
});- A JSON side is checked by its schema, a resource type by
resourceType, a profile by the generatedasProfiled: its stamp and what its type requires. Full validation stays Medplum's, on write. - A failed check is an
OperationErrorcarrying an OperationOutcome with each issue and its path (input.text: Required). In a bot, Medplum answers it with a 400, whichcallOperationthrows as anOperationErrortoo. pushwrites the OperationDefinition for each contract, in anoperationsstep betweenbotsandsubscriptions: its code, level and resource, itsreturnorresultout parameter, and this environment'sBot/<id>, so no file holds an id. It is found again by Plumb's tag;--prunedeletes a removed one, since Medplum ignoresstatus.- Checked before anything is written: a code used twice or one Medplum
already has, a bot
botslacks, a type or instance operation without its resource, and a profile that is not selected (invalid-operation). An OperationDefinition with the same code that Plumb did not write in the project, or any in a project it links, even one Plumb wrote there, stops the push (shadowed-operation): Medplum would run either.
✔ operations plan: 1 to create, 0 to update, 0 to delete
+ OperationDefinition $send-message → Bot messenger
✔ operations applied 1 changeContract modules load as the config does: with Node's type stripping, so a
relative import needs its .ts extension, or with the project's tsx when it
is installed.
Typed bot handlers
With bots in the config, generate writes defineBot, whose event is
typed by what the bot's triggers send it and the secrets it declares:
import { defineBot } from '../fhir/generated/index.js';
export const handler = defineBot('send-reminder', async (medplum, event) => {
if (event.input.resourceType === 'Appointment') {
// from the new-appointment Subscription
} else {
// event.input is the Bot: the schedule ran it
}
const key = event.secrets.SMS_API_KEY.valueString; // only declared secrets
});| Trigger | event.input |
| --- | --- |
| A Subscription on Appointment?… | Appointment, or { deletedResource: Appointment } when it includes delete |
| A schedule | Bot |
| A webhook | string with rawBody, otherwise unknown: the body is the bot's to check |
| An operation | the contract's input, through handleOperation |
Test against a real server
MockClient keeps everything in memory and enforces none of what
plumb.config.ts declares: no profile, no defaultProfile, no strict mode,
no AccessPolicy. Keep it for unit tests. For the claims only a server can
check, a test environment gives each test run a real Medplum, in Docker,
with a project push has converged from the same config.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { globalSetup: ['plumb-fhir/vitest'] },
});The global setup starts Medplum, Postgres and Redis, makes one strict
project for the run, pushes the config into it (profiles, then project),
and loads the seed data. A server already running is reused and left
running; one it started is removed at the end of the run. Tests reach the
project with testProject(), and log in to it with connectAs:
import { connectAs, testProject } from 'plumb-fhir/test';
import { expect, test } from 'vitest';
test('the front desk cannot read observations', async () => {
const medplum = await connectAs(testProject(), { accessPolicy: 'front-desk' });
await expect(medplum.searchResources('Observation')).rejects.toThrow(/forbidden/i);
});
// With a defaultProfile for Patient that requires birthDate.
test('a patient without a birth date is refused', async () => {
const medplum = await connectAs(testProject());
await expect(
medplum.createResource({ resourceType: 'Patient', name: [{ family: 'Synthetic' }] }),
).rejects.toThrow(/birthDate/);
});The project has the config's bots and Subscriptions too, so a test can trigger a bot the way production does:
// With bots: { 'send-reminder': … } and subscriptions:
// { 'new-appointment': { criteria: 'Appointment?status=booked', bot: 'send-reminder' } }
test('a booked appointment runs send-reminder', async () => {
const medplum = await connectAs(testProject());
const appointment = await medplum.createResource({
resourceType: 'Appointment',
status: 'booked',
participant: [{ status: 'accepted', actor: { display: 'Synthetic' } }],
});
const bot = await medplum.searchOne('Bot', { name: 'send-reminder' });
const events = async () =>
JSON.stringify(await medplum.searchResources('AuditEvent', { entity: `Bot/${bot?.id}` }));
await expect.poll(events, { timeout: 15_000 }).toContain(appointment.id);
});connectAs(project) logs in as the project's admin client;
{ client: 'kiosk' } as a client from project.clients; and
{ accessPolicy: 'front-desk', parameters } as a new client whose membership
has that policy, with its parameters ({ patient: { reference: 'Patient/…' } }
for a %patient policy). AccessPolicies are tested by acting as them. A key
the config does not declare throws unknown-client or unknown-policy.
What the test project has beyond what push writes goes in test:
// in plumb.config.ts
test: {
server: '5.1.42', // optional; the installed @medplum/core's release by default
strictMode: true, // the default; false rehearses a loose project
features: ['bots'], // the default, with 'cron' when a bot has a schedule
settings: { intakeEnabled: true }, // merged over project.settings
seed: ['./test/seed/*.json'], // transaction or batch Bundles, loaded in order
bots: { 'send-reminder': { file: './dist/test/send-reminder.cjs' } }, // a test build
},The test server's super admin sets strictMode and features, which
project cannot. Seed files load after the push, so a seed resource that
breaks its profile fails the setup, naming the file and the entry. Keep seed
data synthetic. Secrets resolve from the environment as push resolves them.
- Docker is the one requirement. Without it the setup fails in CI and,
locally, warns, and
testProject()throws. The first run pulls the images, about a minute; a project and its push take seconds. - Other runners call the plain functions from their own global setup:
startServer,createTestProjectandstopServerfromplumb-fhir/test.createTestProjectandmigratetake the config asloadConfigreturns it (aLoadedConfig), and report a push or a run as{ ok, errors, steps }.plumb-fhir/vitesthands the project to the tests in thePLUMB_TEST_PROJECTenvironment variable, whichtestProject()reads. - Bots run on Medplum's
vmcontextruntime in the test server, whatever theirruntime. Hosted Medplum runs them on AWS Lambda, so a test environment cannot show Lambda's differences. vmcontext runs CommonJS that assignsexports.handler, and itsrequirereaches the server's own packages, so a bundle that leaves only@medplum/*external runs there; one built for a Lambda layer names a test build intest.bots. - The super admin exists only in the server Plumb starts, so a test project is never made on any other server.
Keep @medplum/* in step with your server
The types and validateProfiled use the @medplum/* packages you install.
A server on a different Medplum release can validate differently, so keep the
packages on the same release as the server. Plumb supports @medplum/* 5.1.0
and later, TypeScript 5.0 and later (NodeNext or bundler resolution), and
Node ^22.18.0 || >=24.2.0, the same range as Medplum. @medplum/core's own
type declarations need @types/node, or skipLibCheck, as Medplum's own
projects set.
Known limits
The types say what TypeScript can say; validateProfiled and the server check
the rest. Each generated type's doc comment lists the rules it cannot check.
Missing or repeated required slices, and
CodeableConceptbindings, are caught byvalidateProfiledand the server, not the editor. Arrays stay plain arrays, so a slice entry built by hand is not checked against its slice; use the generated builders.Extensions are open. Every extension slicing is open, so a known extension with the wrong shape (a
valueStringwhere avalueCodeis required) compiles unless it is built with its helper.Reference strings are not narrowed. A
Reference<Patient>field accepts{ reference: 'Group/1' }, so thatcreateReference()results stay assignable without a cast. Medplum's validator only warns on the wrong type.Value sets that cannot be listed offline (rules, VSAC and other terminology servers, SNOMED CT) or that have more than
bindings.maxCodescodes (100 by default) keep the field's base type; the doc comment names the value set. Only a server withvalidate-terminologychecks them._fieldprimitive extensions (_birthDatewith a data-absent-reason) are not typed:@medplum/fhirtypeshas no_fieldproperties.What Medplum's parser cannot read, Plumb cannot either. Profiles with a
profileslicing discriminator (IPS Bundle and DiagnosticReport) and US Core Provenance cannot be parsed;name/*skips them with a warning. Slices inside slices (IPS Composition) are parsed inconsistently, so that slicing gets no slice types, and a warning.validateProfiledinherits Medplum's validator: it checks no terminology binding, never matches an extension slice (so a profile that requires an extension fails every resource), and does not check slice contents,closedororderedslicing, narrowed choice types, or rules reached through acontentReference. Seedocs/design/01-generator.md, Testing.Unstamped resources are counted, not checked.
validatedoes not route them;createProfiledstamps new writes.Routing keys are first-level elements,
