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 +34 -0
- package/README.md +57 -11
- package/dist/cjs/testing.cjs +107 -31
- package/dist/cjs/testing.cjs.map +3 -3
- package/dist/cjs/vitest.cjs +107 -31
- package/dist/cjs/vitest.cjs.map +3 -3
- package/dist/esm/cli.mjs +131 -32
- package/dist/esm/cli.mjs.map +3 -3
- package/dist/esm/testing.mjs +107 -31
- package/dist/esm/testing.mjs.map +3 -3
- package/dist/esm/vitest.mjs +107 -31
- package/dist/esm/vitest.mjs.map +3 -3
- package/package.json +1 -1
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`
|
|
561
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
package/dist/cjs/testing.cjs
CHANGED
|
@@ -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
|
|
2305
|
-
|
|
2306
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
3762
|
-
const
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|