plumb-fhir 0.20.0 → 0.22.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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 (2026-10-23)
4
+
5
+ A profile output's OperationDefinition changes, so the next `push` updates it.
6
+
7
+ - **A passing `plumb validate` records a receipt:** one `Basic` in the
8
+ project, coded `validation` as the migration ledger is coded `migration`,
9
+ naming each profile's URL, version and a SHA-256 of its closure, the
10
+ checker's build, and the second the run began by the server's clock. Each
11
+ pass replaces the last. A failing or shadowed run records none.
12
+ - **`push`'s gate and re-check read from the receipt.** When it covers every
13
+ profile the gate checks, at the same version, content and checker, the
14
+ gate reads only what was updated from the second validate began
15
+ (`_lastUpdated=ge…`), and the re-check only what was updated from the
16
+ gate's start: `gate incremental since 2026-10-23T09:00:00.000Z (receipt
17
+ Basic/…): 4 Patient stamped my-patient checked, nothing would fail`. With
18
+ no matching receipt the gate reads everything as before, and names why:
19
+ `Full scan: no receipt from plumb validate.` So a large project validates
20
+ in a step of its own, with a long time limit, and deploys fast (#291).
21
+ - **A profile output's parameter names its profile in `documentation`**
22
+ (`Conforms to <url>`) instead of `targetProfile`, which FHIR's `opd-3`
23
+ allows only on a Reference or canonical parameter (#291).
24
+
25
+ ## 0.21.0 (2026-10-23)
26
+
27
+ - **`push` gates only the changed profiles.** The gate and the re-check read
28
+ what is stamped with a profile this push creates or updates, or whose
29
+ parent or extension it does, not every selected profile's records, and
30
+ say what they read: `489 Patient stamped my-patient checked, nothing would
31
+ fail; 53 unchanged profiles skipped`. With strict mode on, the server
32
+ refused any write failing an already-loaded profile, so its records need
33
+ no second read. With strict mode off, or unknown, every profile is still
34
+ checked. A push changing only terminology runs no check, as the checker
35
+ validates no binding. `plumb validate` remains a full pass.
36
+
3
37
  ## 0.20.0 (2026-10-18)
4
38
 
5
39
  The checker's input changes, so the next `push` redeploys it.
package/README.md CHANGED
@@ -557,8 +557,15 @@ Failed in 40.8s
557
557
  - **Exit codes:** 0 when nothing fails; 1 when something would fail, a type
558
558
  could not be read, or a profile is shadowed; 2 for usage, config and
559
559
  connection errors, and when the checker is missing or from another Plumb
560
- release (run `plumb push` to install it). `validate` never installs or
561
- changes anything.
560
+ release (run `plumb push` to install it). `validate` installs and changes
561
+ nothing; a passing run records only its receipt, below.
562
+ - **A passing run records a receipt:** one `Basic` in the project, coded
563
+ `validation` in Plumb's system, as the [migration ledger](#migrate-stored-data)
564
+ is, holding each profile's URL, version and a SHA-256 of what it was
565
+ checked with (it, its parents, its extensions), the checker's build, and
566
+ the second the run began by the server's clock. Each pass replaces the
567
+ last; the `Basic`'s history keeps them. A failing or shadowed run records
568
+ none.
562
569
 
563
570
  **4. Fix or [migrate](#migrate-stored-data)** the failing records, and
564
571
  validate again until nothing fails.
@@ -575,9 +582,10 @@ plumb push --env prod
575
582
  ✔ connect https://api.medplum.com/ (strict mode off) 320ms
576
583
  ✔ checker plumb-checker 0.6.0 unchanged 90ms
577
584
  ✔ plan load us-core-patient 9.0.0 (+7 dependencies) 210ms
578
- ✔ gate nothing stored would fail 36.1s
585
+ ✔ gate 312 Patient stamped us-core-patient checked, nothing would fail 36.1s
586
+ Strict mode is off, so every profile is checked.
579
587
  ✔ apply 8 created, 0 updated 1.4s
580
- ✔ recheck nothing stored fails 35.7s
588
+ ✔ recheck 312 Patient stamped us-core-patient checked, nothing fails 35.7s
581
589
  Strict mode is off, and only a super admin can turn it on; every stamped resource checked passes its profiles.
582
590
  Done in 76.4s
583
591
  ```
@@ -598,11 +606,29 @@ Done in 76.4s
598
606
  written.
599
607
  - **gate** runs the checker against the planned versions. If any stored
600
608
  resource would fail, `push` refuses and loads nothing. `--dry-run` stops
601
- here, so it is a safe way to install the checker and preview a push.
609
+ here, so it is a safe way to install the checker and preview a push. It
610
+ reads only what is stamped with a profile this push changes: one created
611
+ or updated, or whose parent or extension is. With strict mode on, the
612
+ server already refused any write failing a loaded profile, so an unchanged
613
+ profile's records are skipped, and the step says how many profiles were.
614
+ With strict mode off it checks every selected profile, as `validate` does.
615
+ A push that changes only terminology has nothing for the checker to read,
616
+ since it validates no binding.
617
+ - **A receipt makes the gate incremental.** When the project's `validate`
618
+ receipt covers every profile the gate checks, at the same version and
619
+ content and under the same checker, the gate reads only what was updated
620
+ from the second that run began (`_lastUpdated=ge…`, by the server's
621
+ clock): everything older passed those very profiles. It says so:
622
+ `gate incremental since 2026-10-23T09:00:00.000Z (receipt Basic/…): 4
623
+ Patient stamped …`. Otherwise it reads everything and names why, such as
624
+ `Full scan: no receipt from plumb validate.` or `Full scan: receipt
625
+ Basic/… covers my-patient 2.1.0, not 2.2.0.`, so no receipt is never less
626
+ safe than none existing.
602
627
  - **apply** creates or updates the definitions, terminology first.
603
- - **recheck** checks again at once, to catch a failing write made between the
604
- gate and loading. A failure there fails the push (exit 1), and the profiles
605
- stay loaded, as Postgres keeps a `NOT VALID` constraint.
628
+ - **recheck** checks the same profiles again at once, to catch a failing
629
+ write made between the gate and loading. A failure there fails the push (exit 1), and the profiles
630
+ stay loaded, as Postgres keeps a `NOT VALID` constraint. After an
631
+ incremental gate it reads only what was updated from the gate's start.
606
632
 
607
633
  Exit codes are `validate`'s: a write that fails, or a plan blocked by an
608
634
  untagged or shadowed resource, exits 1. `--json` and `--quiet` work as for
@@ -615,15 +641,29 @@ resource that fails a profile it names; a clean `push` re-check says the
615
641
  stored data is ready.
616
642
 
617
643
  In CI, run `push` where the deploy runs, with the secrets as environment
618
- variables:
644
+ variables. On a large project, validate first, in a step of its own with a
645
+ long time limit, then push: the slow full read is the validate, and the
646
+ deploy's gate reads only what changed since it began, as Postgres adds a
647
+ constraint `NOT VALID` and runs `VALIDATE CONSTRAINT` apart:
619
648
 
620
649
  ```yaml
621
- - run: npx plumb push --env prod
650
+ - run: npx plumb validate --env prod --concurrency 8
651
+ timeout-minutes: 240
652
+ env:
653
+ MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
654
+ MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
655
+ - run: npx plumb push --env prod # gate and recheck read only what changed since
622
656
  env:
623
657
  MEDPLUM_PROD_CLIENT_ID: ${{ secrets.MEDPLUM_PROD_CLIENT_ID }}
624
658
  MEDPLUM_PROD_CLIENT_SECRET: ${{ secrets.MEDPLUM_PROD_CLIENT_SECRET }}
625
659
  ```
626
660
 
661
+ Validate with the same `plumb-fhir` and `plumb.config.ts` as the push, or
662
+ the receipt does not match and the gate reads everything. `validate` needs
663
+ the checker this `plumb-fhir` installs, and exits 2 (`checker-outdated`)
664
+ until a push has installed it: land a `plumb-fhir` upgrade in a deploy of its
665
+ own, before the profile change.
666
+
627
667
  ## Migrate stored data
628
668
 
629
669
  `validate` says which stored records a stricter profile would refuse; a
@@ -634,9 +674,15 @@ database's **check, backfill, then tighten**:
634
674
  npx plumb validate --env prod # 312 Patients fail us-core-patient: birthDate missing
635
675
  npx plumb migrate --env prod # dry run: 312 to change, and a forecast
636
676
  npx plumb migrate --env prod --write # the backfill, resumable
637
- npx plumb push --env prod # the gate passes; the stricter profile loads
677
+ npx plumb validate --env prod # nothing fails: the receipt is recorded
678
+ npx plumb push --env prod # the gate reads only what changed since; the stricter profile loads
638
679
  ```
639
680
 
681
+ The validate is the slow step: it reads every stamped record. Run it apart,
682
+ with a long time limit, and the push that follows stays fast, as the gate and
683
+ re-check read only what was updated after the validate began. Without a
684
+ receipt for the same profile versions, `push` reads everything, as before.
685
+
640
686
  **Declare a migration** in a module the config lists. `plumb migrate new
641
687
  patient-birthdate` scaffolds `<date>-patient-birthdate.ts` in the first
642
688
  `modules` folder:
@@ -2163,9 +2163,36 @@ async function loadAndConnect(options, result2, step) {
2163
2163
  }
2164
2164
 
2165
2165
  // src/conformance.ts
2166
+ var RECEIPT = `${PLUMB_SYSTEM}#validation`;
2167
+ function covered(gated) {
2168
+ return gated.profiles.map((p) => ({
2169
+ url: p.url,
2170
+ ...p.sd.version ? { version: p.sd.version } : {},
2171
+ hash: (0, import_node_crypto5.createHash)("sha256").update(JSON.stringify(closure([p.url], gated))).digest("hex")
2172
+ }));
2173
+ }
2174
+ var findReceipt = (medplum) => medplum.searchOne("Basic", owned(medplum, { code: `${PLUMB_SYSTEM}|validation` }));
2175
+ async function receiptFor(medplum, gated, checker) {
2176
+ const held = await findReceipt(medplum);
2177
+ const value = held?.extension?.find((e) => e.url === RECEIPT)?.valueString;
2178
+ if (!held || !value) return { reason: "no receipt from plumb validate" };
2179
+ const receipt = JSON.parse(value);
2180
+ const id = `receipt Basic/${held.id}`;
2181
+ if (receipt.checker !== checker) return { reason: `${id} is from another checker build` };
2182
+ for (const p of covered(gated)) {
2183
+ const name = p.url.slice(p.url.lastIndexOf("/") + 1);
2184
+ const found = receipt.profiles.find((r) => r.url === p.url);
2185
+ if (!found) return { reason: `${id} does not cover ${name}` };
2186
+ if (found.version !== p.version) {
2187
+ return { reason: `${id} covers ${name} ${found.version}, not ${p.version}` };
2188
+ }
2189
+ if (found.hash !== p.hash) return { reason: `${id} covers other content of ${name}` };
2190
+ }
2191
+ return { id: held.id, since: receipt.began };
2192
+ }
2166
2193
  async function checkStored(medplum, loaded, botId, options) {
2167
2194
  const profiles = loaded.profiles.map((p) => [p.url, p.sd.version]).sort();
2168
- const modes = [options.full ?? false, options.forecast !== void 0];
2195
+ const modes = [options.full ?? false, options.forecast !== void 0, options.since ?? null];
2169
2196
  const key = (0, import_node_crypto5.createHash)("sha256").update(JSON.stringify([options.filename, profiles, ...modes])).digest("hex");
2170
2197
  const { saved, warnings: warnings2 } = await start(medplum, options, key);
2171
2198
  const resumed = saved.pages;
@@ -2194,6 +2221,7 @@ async function checkStored(medplum, loaded, botId, options) {
2194
2221
  );
2195
2222
  }
2196
2223
  const checked = {
2224
+ began: new Date(Date.parse(saved.before) - 1e3).toISOString(),
2197
2225
  types: {},
2198
2226
  profiles: {},
2199
2227
  resumed,
@@ -2301,10 +2329,12 @@ async function checkType(medplum, saved, options, botId, input, onCore) {
2301
2329
  let type = saved.types[resourceType];
2302
2330
  if (type?.done) return;
2303
2331
  if (!type) {
2304
- const count2 = await medplum.search(resourceType, {
2305
- _summary: "count",
2306
- _total: "accurate"
2307
- });
2332
+ const since = options.since ? { slice: { from: options.since } } : void 0;
2333
+ const count2 = await medplum.search(resourceType, [
2334
+ ...since ? readQuery({ ...input, ...since }) : [],
2335
+ ["_summary", "count"],
2336
+ ["_total", "accurate"]
2337
+ ]);
2308
2338
  type = {
2309
2339
  exists: count2.total ?? 0,
2310
2340
  read: 0,
@@ -2314,7 +2344,7 @@ async function checkType(medplum, saved, options, botId, input, onCore) {
2314
2344
  silent: { unknown: 0, versioned: 0, empty: 0 },
2315
2345
  otherProfiles: {},
2316
2346
  stampedOther: 0,
2317
- slices: await split(medplum, input, options.concurrency),
2347
+ slices: await split(medplum, input, options.concurrency, options.since),
2318
2348
  done: false
2319
2349
  };
2320
2350
  saved.types[resourceType] = type;
@@ -2329,7 +2359,7 @@ async function checkType(medplum, saved, options, botId, input, onCore) {
2329
2359
  merge(saved, current, page, resourceType);
2330
2360
  saved.pages++;
2331
2361
  write2(options.reportPath, saved);
2332
- const progress = input.full ? { read: current.read, of: current.exists } : { read: current.stamped, of: current.of ?? 0 };
2362
+ const progress = input.full ? { read: current.read, of: current.exists } : { read: current.stamped, of: current.of ?? (options.since ? current.exists : 0) };
2333
2363
  return options.onPage?.(resourceType, saved.pages, progress);
2334
2364
  });
2335
2365
  }
@@ -2363,16 +2393,18 @@ async function drive(run, input, type, save) {
2363
2393
  var SLICES_PER_WORKER = 4;
2364
2394
  var MIN_SLICE = 1e3;
2365
2395
  var MIN_SPAN_MS = 1e3;
2366
- async function split(medplum, input, concurrency = 1) {
2367
- if (concurrency <= 1 || !input.before) return [{ done: false }];
2396
+ async function split(medplum, input, concurrency = 1, since) {
2397
+ if (concurrency <= 1 || !input.before)
2398
+ return [{ ...since ? { from: since } : {}, done: false }];
2368
2399
  const count2 = async (from, to) => {
2369
2400
  const slice = { ...from === void 0 ? {} : { from: iso(from) }, to: iso(to) };
2370
2401
  const query = [...readQuery({ ...input, slice }), ["_summary", "count"]];
2371
2402
  return (await medplum.search(input.resourceType, query)).total ?? 0;
2372
2403
  };
2373
2404
  const end = Date.parse(input.before);
2405
+ const first = since ? Date.parse(since) : void 0;
2374
2406
  const ranges = [
2375
- { to: end, n: await count2(void 0, end) }
2407
+ { ...first === void 0 ? {} : { from: first }, to: end, n: await count2(first, end) }
2376
2408
  ];
2377
2409
  const target = Math.max(
2378
2410
  MIN_SLICE,
@@ -2388,8 +2420,9 @@ async function split(medplum, input, concurrency = 1) {
2388
2420
  ranges.splice(ranges.indexOf(largest), 1, { ...largest, to: mid, n: left }, right);
2389
2421
  }
2390
2422
  const kept = ranges.filter((r, i) => r.n > 0 || i === ranges.length - 1);
2423
+ const lower = (i) => i === 0 ? first : kept[i - 1]?.to;
2391
2424
  return kept.map((r, i) => ({
2392
- ...i === 0 ? {} : { from: iso(kept[i - 1]?.to ?? 0) },
2425
+ ...lower(i) === void 0 ? {} : { from: iso(lower(i)) },
2393
2426
  to: iso(r.to),
2394
2427
  done: false
2395
2428
  }));
@@ -3395,7 +3428,8 @@ function operationDefinition(contract, botId, typeOf) {
3395
3428
  min: 1,
3396
3429
  max: "1",
3397
3430
  type,
3398
- ...!json && output.includes(":") ? { targetProfile: [output] } : {}
3431
+ // FHIR's opd-3 allows targetProfile on Reference and canonical parameters only.
3432
+ ...!json && output.includes(":") ? { documentation: `Conforms to ${output}` } : {}
3399
3433
  }
3400
3434
  ],
3401
3435
  ...botId ? { extension: [{ url: IMPLEMENTATION, valueReference: { reference: `Bot/${botId}` } }] } : {},
@@ -3758,22 +3792,15 @@ async function push(unscoped2) {
3758
3792
  if (result2.plan.some((p) => p.action === "shadowed" || p.action === "linked")) return step.done();
3759
3793
  if (changes.length === 0) return finish(true);
3760
3794
  const filename = checkerFilename(options.checker.code, options.checker.version);
3761
- result2.reportPath = options.reportPath;
3762
- const gated = gatedProfiles(loaded, changes);
3763
- const check = (name) => checkStored(medplum, gated, botId, { ...options, filename }).catch((err) => {
3764
- step.fail(name, [{ code: "checker-failed", message: (0, import_core13.normalizeErrorString)(err) }]);
3765
- return void 0;
3766
- });
3767
- result2.gate = await check("gate");
3768
- if (!result2.gate) return result2;
3769
- const gate = judge(result2.gate, gated.profiles.length);
3770
- step.finish(
3771
- "gate",
3772
- gate.failed ? failingSummary(result2.gate) : "nothing stored would fail",
3773
- gate.failed ? await refusal(medplum, result2.gate, options) : options.dryRun ? ["Dry run: nothing loaded."] : [],
3774
- gate.failed
3795
+ const gated = gatedProfiles(loaded, changes, result2.strictMode === true);
3796
+ const check = (name, since2) => checkStored(medplum, gated, botId, { ...options, filename, ...since2 ? { since: since2 } : {} }).catch(
3797
+ (err) => {
3798
+ step.fail(name, [{ code: "checker-failed", message: (0, import_core13.normalizeErrorString)(err) }]);
3799
+ return void 0;
3800
+ }
3775
3801
  );
3776
- if (gate.failed) return step.done();
3802
+ const gate = { gated, filename, check };
3803
+ if (!await gateStep(medplum, gate, options, result2, step)) return step.done();
3777
3804
  if (options.dryRun) return finish(true);
3778
3805
  try {
3779
3806
  await apply(medplum, changes, planned, held);
@@ -3782,17 +3809,51 @@ async function push(unscoped2) {
3782
3809
  }
3783
3810
  const created = changes.filter((p) => p.action === "create").length;
3784
3811
  step.finish("apply", `${created} created, ${changes.length - created} updated`);
3785
- result2.recheck = await check("recheck");
3812
+ if (gated.profiles.length === 0) return finish(true);
3813
+ const since = result2.receipt && result2.gate?.began;
3814
+ result2.recheck = await check("recheck", since);
3786
3815
  if (!result2.recheck) return result2;
3787
3816
  const recheck = judge(result2.recheck, gated.profiles.length);
3788
3817
  step.finish(
3789
3818
  "recheck",
3790
- recheck.failed ? failingSummary(result2.recheck, true) : "nothing stored fails",
3819
+ `${since ? `incremental since ${since}: ` : ""}${recheck.failed ? failingSummary(result2.recheck, true) : scopedSummary(gated, "nothing fails", result2.recheck)}`,
3791
3820
  recheckNotes(recheck.failed, result2.strictMode === true),
3792
3821
  recheck.failed
3793
3822
  );
3794
3823
  return finish(!recheck.failed);
3795
3824
  }
3825
+ async function gateStep(medplum, {
3826
+ gated,
3827
+ filename,
3828
+ check
3829
+ }, options, result2, step) {
3830
+ const dryRun = options.dryRun ? ["Dry run: nothing loaded."] : [];
3831
+ if (gated.profiles.length === 0) {
3832
+ step.finish("gate", scopedSummary(gated, "no profile changed, nothing to check"), dryRun);
3833
+ return true;
3834
+ }
3835
+ const found = await receiptFor(medplum, gated, filename).catch((err) => ({
3836
+ reason: `the receipt was not readable: ${(0, import_core13.normalizeErrorString)(err)}`
3837
+ }));
3838
+ if ("since" in found) result2.receipt = found;
3839
+ result2.reportPath = options.reportPath;
3840
+ result2.gate = await check("gate", result2.receipt?.since);
3841
+ if (!result2.gate) return false;
3842
+ const gate = judge(result2.gate, gated.profiles.length);
3843
+ const mode = "since" in found ? `incremental since ${found.since} (receipt Basic/${found.id}): ` : "";
3844
+ const scan = "reason" in found ? [`Full scan: ${found.reason}.`] : [];
3845
+ const full = result2.strictMode ? [] : ["Strict mode is off, so every profile is checked."];
3846
+ step.finish(
3847
+ "gate",
3848
+ `${mode}${gate.failed ? failingSummary(result2.gate) : scopedSummary(gated, "nothing would fail", result2.gate)}`,
3849
+ [
3850
+ ...scan,
3851
+ ...gate.failed ? await refusal(medplum, result2.gate, options) : [...full, ...dryRun]
3852
+ ],
3853
+ gate.failed
3854
+ );
3855
+ return !gate.failed;
3856
+ }
3796
3857
  async function prepare(options, out, result2, step) {
3797
3858
  const ready = await loadAndConnect(options, result2, step);
3798
3859
  if (!ready) return void 0;
@@ -4107,13 +4168,28 @@ async function heldCopies(medplum, planned) {
4107
4168
  }
4108
4169
  return { held, server, ours: (d) => d.meta?.project === project.id };
4109
4170
  }
4110
- function gatedProfiles(loaded, changes) {
4171
+ function gatedProfiles(loaded, changes, strictMode) {
4111
4172
  const selected = new Set(loaded.profiles.map((p) => p.url));
4112
4173
  const others = changes.flatMap((c) => {
4113
4174
  const sd = loaded.definitions.get(c.url)?.resource;
4114
4175
  return !selected.has(c.url) && sd?.resourceType === "StructureDefinition" && sd.kind === "resource" && sd.derivation === "constraint" ? [{ url: c.url, sd }] : [];
4115
4176
  });
4116
- return { profiles: [...loaded.profiles, ...others], definitions: loaded.definitions };
4177
+ const all = [...loaded.profiles, ...others];
4178
+ const changed = new Set(changes.map((c) => c.url));
4179
+ const profiles = strictMode ? all.filter((p) => closure([p.url], loaded).some((sd) => changed.has(sd.url))) : all;
4180
+ return { profiles, definitions: loaded.definitions, skipped: all.length - profiles.length };
4181
+ }
4182
+ function scopedSummary(gated, verdict, checked) {
4183
+ const types = [...new Set(gated.profiles.map((p) => p.sd.type))].sort();
4184
+ const read2 = types.map((type) => {
4185
+ const names = gated.profiles.filter((p) => p.sd.type === type).map((p) => p.url.slice(p.url.lastIndexOf("/") + 1));
4186
+ const which = names.length > 3 ? `${names.length} profiles` : names.join(", ");
4187
+ return `${checked?.types[type]?.stamped ?? 0} ${type} stamped ${which}`;
4188
+ });
4189
+ return [
4190
+ checked ? `${read2.join(", ")} checked, ${verdict}` : verdict,
4191
+ gated.skipped > 0 && `${gated.skipped} unchanged profile${gated.skipped === 1 ? "" : "s"} skipped`
4192
+ ].filter(Boolean).join("; ");
4117
4193
  }
4118
4194
  async function apply(medplum, changes, planned, held) {
4119
4195
  for (const change of changes) {