@cosyte/transform 0.0.9 → 0.1.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 +178 -0
- package/README.md +195 -20
- package/dist/index.cjs +2563 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1684 -79
- package/dist/index.d.ts +1684 -79
- package/dist/index.mjs +2499 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +46 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,183 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 22e4351: **This is 0.1.0, the first release of `@cosyte/transform` whose public API we treat as settled.**
|
|
8
|
+
|
|
9
|
+
What is covered, and what you can build against:
|
|
10
|
+
- HL7 v2 to FHIR R4, grounded on the HL7 Version 2 to FHIR Implementation Guide: the six datatype
|
|
11
|
+
converters (`toFhirDateTime`, `toFhirIdentifier`, `toFhirCodeableConcept`, `toFhirHumanName`,
|
|
12
|
+
`toFhirAddress`, `toFhirQuantity`), and `toFhir(msg)`, which turns a parsed `@cosyte/hl7` message
|
|
13
|
+
into a FHIR message Bundle for ADT (with `AL1`, `DG1`, `PR1` and `IN1`), ORU^R01, ORM_O01 and
|
|
14
|
+
OML_O21 (with `RXO` and `TQ1`), VXU_V04, SIU_S12 and MDM_T02.
|
|
15
|
+
- The fail-safe rule: a naked timestamp, an assigning authority with no registered system, an
|
|
16
|
+
unmapped code or a unit that is not UCUM becomes a typed issue carrying a code and a location,
|
|
17
|
+
never a value, and never a guessed FHIR value. `toOperationOutcome` renders the issues as FHIR.
|
|
18
|
+
- A narrow FHIR to v2 path: `toV2Patient` and `toV2Observation` emit a complete v2 message from the
|
|
19
|
+
fields whose mapping inverts one to one, with the trigger always supplied by you.
|
|
20
|
+
|
|
21
|
+
What the version promises. The exported names, options, result shapes and issue codes are the
|
|
22
|
+
surface we keep stable: a new issue code is an addition, and renaming or removing one is a breaking
|
|
23
|
+
change. While the package is below 1.0, a breaking change bumps the minor version (0.1 to 0.2) and
|
|
24
|
+
is called out in this changelog; a fix that changes no emitted value ships as a patch.
|
|
25
|
+
`@cosyte/hl7` and `@cosyte/fhir` are peer dependencies you install beside this package.
|
|
26
|
+
|
|
27
|
+
What is not covered yet. None of the guide's seven published test messages yet produces a Bundle
|
|
28
|
+
that is clean against FHIR R4 plus the US Core 9.0.0 profiles; six of the seven are clean against
|
|
29
|
+
base R4, and the conformance report in the repository lists every finding. No profiles and no
|
|
30
|
+
terminology content ship, so a field whose guide target is SNOMED CT stays structural unless you
|
|
31
|
+
supply a ConceptMap. `RXE` has no map in the guide and is flagged, never assembled. The FHIR to v2
|
|
32
|
+
path does not build a visit-carrying ADT from a `Patient` and an `Encounter`, and it is not a round
|
|
33
|
+
trip: a many-to-one mapping is refused rather than inverted.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- 76984bb: Add a narrow reverse path, FHIR to HL7 v2: `toV2Patient(patient, trigger)` emits a complete `ADT` message carrying a `PID`, and `toV2Observation(observation, trigger)` a complete `ORU` message carrying an `OBX` (roadmap §Phase 7, shipped for two of the three scoped shapes).
|
|
38
|
+
|
|
39
|
+
Each takes the FHIR resource **plus the v2 trigger the message should carry**, and returns the same `{ value, issues }` envelope the forward direction uses, where `value` is a complete `@cosyte/hl7` message. The trigger is required and is never inferred: no FHIR resource carries an HL7 v2 message trigger, so a missing, empty or non-string one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic without calling the builder at all, and a trigger that is not a bare token (whitespace, or a delimiter that would split MSH-9) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE` rather than being trimmed into something else.
|
|
40
|
+
|
|
41
|
+
**This direction is lossy by design and is not a round-trip.** The IG maps v2 to FHIR and publishes no map the other way, so every row here is the inverse of a published row, and an inverse is only usable where the forward row is one-to-one. `invertCodeMap` enforces exactly that, and the many-to-one rows are refused with `TRANSFORM_CODE_NOT_INVERTIBLE` rather than resolved to their likeliest source code: `gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and `Observation.status` `entered-in-error`. An element with no v2 field in this map is flagged `TRANSFORM_NO_V2_TARGET`, a value v2 cannot carry unchanged is left out with `TRANSFORM_VALUE_NOT_REPRESENTABLE`, and a coding system with no v2 mnemonic is flagged `TRANSFORM_CODE_SYSTEM_NOT_V2` rather than written under a borrowed table. Nothing asserts that a message transformed to FHIR and back equals the original; the property suite verifies only that every emitted message parses back under `parseHL7` without a fatal error and carries the caller's trigger verbatim in MSH-9.
|
|
42
|
+
|
|
43
|
+
Seven issue codes are added (`TRANSFORM_MISSING_TRIGGER`, `TRANSFORM_UNSUPPORTED_RESOURCE`, `TRANSFORM_RESOURCE_MALFORMED`, `TRANSFORM_NO_V2_TARGET`, `TRANSFORM_VALUE_NOT_REPRESENTABLE`, `TRANSFORM_CODE_NOT_INVERTIBLE`, `TRANSFORM_CODE_SYSTEM_NOT_V2`), additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed. They are `ISSUE_CODES` entries because they are returned rather than thrown, which is the structural line between the two registries in this package.
|
|
44
|
+
|
|
45
|
+
The third scoped shape, a `Patient` + `Encounter` visit-carrying ADT, is deferred with a dated rationale in `documentation/decisions/0003`: the vendored parser exports no ADT assembly entry point, and hand-assembling that message structure here would invert the tier split ADR 0001 draws.
|
|
46
|
+
|
|
47
|
+
- 09138da: Publish what this library's output conformance actually is, instead of a README sentence that reads
|
|
48
|
+
stronger than the check behind it. The guide's seven published v2 test messages are now carried in
|
|
49
|
+
the repository, transformed, and every resource of every resulting Bundle is validated against pinned
|
|
50
|
+
FHIR R4 4.0.1 definitions and the pinned `hl7.fhir.us.core` 9.0.0 profiles, with the outcome committed
|
|
51
|
+
as `documentation/conformance/report.md` and `documentation/conformance/result.json`.
|
|
52
|
+
|
|
53
|
+
**Nothing about the library's output moved.** No mapping, no emitted value, no issue code and no emit
|
|
54
|
+
gate changed, and the package entry point exports exactly what it exported before. This is an
|
|
55
|
+
apparatus, so the result it publishes on day one is a measurement of the library as it stands.
|
|
56
|
+
|
|
57
|
+
**The result, stated plainly: none of the seven messages produces a Bundle with zero error-severity
|
|
58
|
+
results** against R4 plus those profiles. Against base R4 4.0.1 alone, six of the seven are clean; the
|
|
59
|
+
exception is the `SIU_S12`, whose `Appointment` is `booked` with no start, which R4's own `app-3`
|
|
60
|
+
invariant forbids. The profile findings are mostly one shape: US Core requires elements the guide's
|
|
61
|
+
segment maps publish no row for, among them `Encounter.type`, `Coverage.relationship`,
|
|
62
|
+
`Observation.category` and `DocumentReference.category`, and this library leaves an ungrounded element
|
|
63
|
+
absent rather than guessing at it. Every finding is in the report with its element path, the profile
|
|
64
|
+
canonical it came from and that profile's version, so a result is reproducible against a pin rather
|
|
65
|
+
than against whatever was current. Each per-message table prints one row per finding per resource
|
|
66
|
+
instance, naming which Bundle entry it came from, so the rows count to the number stated above them
|
|
67
|
+
rather than collapsing sibling resources that fail the same way into one.
|
|
68
|
+
|
|
69
|
+
The README's old claim, that every emitted resource "is validated against `@cosyte/fhir` before it
|
|
70
|
+
ships", is gone. What it described is a small internal required-element schema, and the replacement
|
|
71
|
+
says so and points at the measurement. The opening paragraph, where a reader meets the word
|
|
72
|
+
"validated" first, now carries that qualification where they meet it rather than leaving it in a
|
|
73
|
+
section 130 lines down the page.
|
|
74
|
+
|
|
75
|
+
Three things keep the number honest. The published result is checked against a hand-written claims
|
|
76
|
+
register on every test run, in BOTH directions: a pair declared conformant that starts failing breaks
|
|
77
|
+
the build, and a pair declared non-conformant that stops failing breaks it too, so the register cannot
|
|
78
|
+
rot into a list of stale excuses. The report states which classes of check were performed and which
|
|
79
|
+
were not, external terminology resolution and mapping correctness among the latter, so it cannot be
|
|
80
|
+
read as a full implementation-guide validator's output. And the two definition packages are carried in
|
|
81
|
+
the repository and verified by digest at run time, with no network fetch and no fallback: an absent,
|
|
82
|
+
unreadable or mismatched package fails the run explicitly rather than reporting a pass, as do an empty
|
|
83
|
+
corpus, a zero-document run, and a corpus message that cannot be parsed or transformed.
|
|
84
|
+
|
|
85
|
+
The corpus fixtures carry their own provenance, including the one line the guide's own page mangles
|
|
86
|
+
(an `OBX` whose Base64 component the page emits as markup), which is recorded as published rather than
|
|
87
|
+
repaired with invented v2 content.
|
|
88
|
+
|
|
89
|
+
- 69fc3a2: Carry the diagnoses, procedures and insurance a v2 admit states into the bundle it produces. A `DG1` now becomes a `Condition`, a `PR1` a `Procedure` and an `IN1` a `Coverage`, each wired to the bundle `Patient` by the guide's own ADT_A01 message-map rows, and each `Condition` is referenced back from `Encounter.diagnosis` when the same message also produced an Encounter. Bundles therefore carry three resource types they did not before, and the completeness diagnostic stops reporting those three names as library gaps wherever their occurrences reach an emitted resource. A message carrying none of the three produces exactly the bundle and the issue list it produced before.
|
|
90
|
+
|
|
91
|
+
The elements are the ones the published segment maps ground, and no others. `Condition` takes DG1-3 and DG1-4 as `code` and `code.text`, DG1-5 as `onsetDateTime`, DG1-19 as `recordedDate` and DG1-20 as `identifier`. `Procedure` takes PR1-3 and PR1-4 as `code` and `code.text` (the description only where the coded field carries no original text, per the row's own condition), PR1-6 as `category`, PR1-15 as `reasonCode`, PR1-19 as `identifier`, and PR1-5 with PR1-7 as either `performedDateTime` or a `performedPeriod`. `Coverage` takes IN1-2 as `identifier`, IN1-12 and IN1-13 as `period`, IN1-15 as `type`, and IN1-10 (where its identifier type is `SN`) and IN1-49 as the subscriber-id extension.
|
|
92
|
+
|
|
93
|
+
Four refusals matter to a consumer. **`Coverage.status` is never asserted:** no published row grounds a coverage status, and R4's binding has no neutral member, so the element ships value-absent with a `data-absent-reason` of `unknown` and a `TRANSFORM_REQUIRED_ELEMENT_UNKNOWN` issue. Read it as unknown, not as active coverage. **`Coverage.payor` names the insurer without resolving it:** IN1-4.1 becomes a reference `display` with no literal reference, because no `Organization` resource is built, and an `IN1` that names no insurance company is withheld entirely with a diagnostic naming the occurrence, since `payor` is required and nothing else grounds it. **`Procedure.status` is the `unknown` the map's own row directs** where the message context determines none; `completed` is never selected from a message that did not say so. **A DG1-21 of `D` is the only action code that sets `verificationStatus`** (`entered-in-error`, the value the row assigns); every other published Table 0206 code leaves the element absent with a `TRANSFORM_CODE_UNMAPPED`.
|
|
94
|
+
|
|
95
|
+
Nothing is dropped without saying so. A diagnosis that grounds neither a code nor a description still becomes a `Condition`, with its empty `code` flagged. With no `Patient` in the bundle every Condition, Procedure and Coverage is withheld and declared per occurrence, naming the reference it could not anchor, so no reference ever points outside the bundle. Every row of the three maps whose target needs a `Practitioner`, `Location` or `Organization` this tier does not build, or resolves by identifier rather than by bundle position, raises a `TRANSFORM_ELEMENT_DROPPED` naming the v2 field and the FHIR path it did not build. One declaration comes from outside those three maps and is scoped to them: a visit that states a financial class (PV1-20), which the guide routes into the same `Coverage` an `IN1` creates, is declared on a message that carries an `IN1`, and never on one that does not. No issue code is added, renamed or removed, and no existing conversion changes.
|
|
96
|
+
|
|
97
|
+
- 088e29f: Give an ORU consumer the typed value the guide already publishes, instead of a string to re-parse. Five OBX-2 value types that used to degrade to `valueString` plus a dropped-element flag now reach their mapped FHIR target, and an ORU's specimens and per-result comments reach the bundle.
|
|
98
|
+
|
|
99
|
+
`DR` becomes `Observation.valuePeriod`, `NR` becomes `valueRange`, `TM` becomes `valueTime`, and `NA` becomes `valueSampledData`. An `ED` whose OBX-5.4 says `Base64` becomes the guide's named `valueAttachment` extension, and its payload is copied from OBX-5.5 **byte-for-byte**: not decoded, not re-encoded, not normalized, not validated, not truncated. A bundle that previously carried a raw string in these five cases now carries a period, a range, a time, a waveform or an attachment, so a consumer reading `value[x]` sees a different shape for the same message.
|
|
100
|
+
|
|
101
|
+
The fail-safe floor is unchanged and still reached wherever the map cannot carry the value faithfully: a `DR` neither of whose bounds is a dateTime this library will emit, an `NR` neither of whose bounds is a `decimal` FHIR carries unaltered, a `TM` carrying a UTC offset (an R4 `time` admits none, and discarding one would move the clinical instant), an `NA` carrying a magnitude that would have to be rewritten, and an `ED` under any encoding other than `Base64`, all fall back to the raw OBX-5 text as `valueString` with `TRANSFORM_ELEMENT_DROPPED`. Where the fallback fires for one of these five, it now carries the **whole** OBX-5 rather than its first component, so a two-ended `DR` no longer loses its second bound on the way out. `RP` is deliberately untouched: its extension target carries the guide's own comment that it is unresolved.
|
|
102
|
+
|
|
103
|
+
A `SampledData` **never asserts an origin or a period**. R4 makes both required and the guide sources neither, so each ships value-absent with a `data-absent-reason` and a `TRANSFORM_REQUIRED_ELEMENT_UNKNOWN`, and the observation still emits. `.dimensions` and `.data` follow the guide's own worked examples, with `E` for a position a repetition did not carry.
|
|
104
|
+
|
|
105
|
+
An `SPM` in an ORU now becomes a `Specimen` that the `DiagnosticReport` scoping it references, so it is no longer reported as a segment that reached nothing. A report the emit gate withholds takes its specimens with it: the bundle never carries a `DiagnosticReport.specimen` pointing at an absent resource, nor a `Specimen` entry orphaned by a report that was never emitted. `Specimen.status` is never asserted, because the only row that reaches it does so through a Table 0136 value map this library does not carry, and the parent, collection-quantity and status rows are declared rather than guessed.
|
|
106
|
+
|
|
107
|
+
An `NTE` inside an ORU's OBSERVATION group now becomes `Observation.note`, one annotation per comment repetition, with NTE-6 as its time. The PATIENT-level and ORDER_OBSERVATION-level `NTE` rows publish no FHIR target, so neither reaches an observation however close to one it sits, and both are still reported as unread.
|
|
108
|
+
|
|
109
|
+
No issue code is added, renamed or removed, and no diagnostic carries a value: not an attachment payload, not a specimen identifier, not a note's text, not an observation magnitude.
|
|
110
|
+
|
|
111
|
+
- f753c72: Refuse a PHI scan that withdrew a target it had enumerated. `scripts/phi-scan.ts` still requires a whole-file `--allow-fixture <path>` bypass to carry a logged `### <path>` entry in `phi-scan-overrides.md`, and an unlogged one is still rejected before any target is read. What changes is what a logged one buys: an audit trail, and nothing else. The run reads and reports every target it did not withdraw, and then exits `2`, the could-not-complete code, naming what it withdrew. The flag can no longer reach exit `0` in any mode.
|
|
112
|
+
|
|
113
|
+
The reason is a false clean that the exit code could not express. A withdrawn target is a file the run enumerated and then never opened, and a scan that did not open a file has no verdict about it. While the bypass was honoured the withdrawal left no trace in the exit code at all, so the same invocation over a corpus whose only violator was withdrawn reported no hits and exited `0`. Nothing about what the scanner detects is narrowed, and no route that passes no bypass moves: the pre-commit hook and the whole-corpus sweep still exit `0` clean and `1` on hits, and the suite pins both as controls alongside the new refusal.
|
|
114
|
+
|
|
115
|
+
The dependency override for `js-yaml` moves to the range the advisory now covers, `>=4.0.0 <4.3.0` resolved at `4.3.0`, and the superseded entry beside it is removed rather than left as a second answer to the same question. Install hardening is declared at the repository root, a 24 hour cooldown on newly published versions and a trust policy that fails an install when a package's trust evidence is weaker than an earlier version of the same package. Those two settings need a package manager that knows them, so the pinned `pnpm` is raised to the version that does; without that raise the file would have decorated rather than defended, and the pinned one refused to install with the file present at all.
|
|
116
|
+
|
|
117
|
+
The agent instruction file is brought back under its declared size by pointing at the long form it already carries in `documentation/agent-notes.md`, which gains the bypass contract in full. No guidance is dropped, every required heading stays, and the checked contract between the two files stays green.
|
|
118
|
+
|
|
119
|
+
- 990be26: No runtime impact: the repository's own PHI scanner now checks every HL7 v2 field number it reads against a published HL7 v2.5.1, and reads seven PHI-bearing fields it previously disclosed as unread.
|
|
120
|
+
|
|
121
|
+
The segment field list is the detector in this package: the cross-cutting SSN/email floor finds nothing in a corpus whose messages are inline v2 string literals, so a wrong field number is either a missed leak or a false positive on a clinical field. Fifteen numbers had never been checked against any published source (the whole GT1 row, plus PID-6, PID-9, PID-19, PID-20, NK1-30, NK1-33, IN1-18 and IN1-19). All fifteen are now checked against the v2.5.1 segment attribute tables in Chapter 3 (PID 3.4.2, NK1 3.4.5) and Chapter 6 (GT1 6.5.5, IN1 6.5.6), cross-checked against a second version-pinned publication, and none of them was wrong. The GT1 clause citation was: it said 6.5.4.
|
|
122
|
+
|
|
123
|
+
Every row in the scanner's coverage table and in the suite's coverage case now carries the v2.5.1 item number the field number was corroborated by, which is the standard's own stable identifier for an element.
|
|
124
|
+
|
|
125
|
+
Seven fields that a review had measured as reported-clean are read now, because the same published tables ground them: NK1-26, NK1-31, NK1-32, NK1-37, GT1-2, GT1-4 and IN1-49. This is a union with the previous list rather than a replacement, and the superset is pinned cell by cell: every field that reported before still reports, every deliberate non-field (IN1-17, IN1-7, PID-10, PID-18, NK1-3, GT1-11, PV1-19) still reports nothing, and the tracked corpus stays clean. `PHI_SEGMENTS` is derived from the union of all five field tables instead of one of them, so a segment added to a single table can no longer go silently unlocated.
|
|
126
|
+
|
|
127
|
+
- 8a2f73f: Carry the allergies an HL7 v2 message states into the bundle it produces. A message that contains an `AL1` segment now yields one `AllergyIntolerance` per occurrence, wired to the `Patient` in the same bundle, so a returned bundle contains a resource type it did not contain before. If you branch on the resource types you receive, or count entries, this is the change to read: an allergy list that used to disappear between the v2 feed and the FHIR record now arrives, and the completeness diagnostic stops reporting an `AL1` it emits.
|
|
128
|
+
|
|
129
|
+
Every value is grounded on a published HL7 Version 2 to FHIR ConceptMap, transcribed firsthand from the guide at 1.0.0 (published 2025-10-07, retrieved 2026-08-28) and exported beside `IG_ALLERGY_VALUE_MAPS_VERSION` so a later release of the guide can be told apart from a defect here. `clinicalStatus` is the `active` the guide assigns, for the reason the guide gives: constraint ait-1 requires one and no `AL1` component can state the retraction that would excuse its absence. `AL1-3` becomes `code`, `AL1-5` becomes `reaction.manifestation.text`, one manifestation per repetition.
|
|
130
|
+
|
|
131
|
+
`AL1-2` is resolved against two separate maps over Table 0127, one to `category` and one to `type`, independently, because that is what the guide publishes and their unmapped sets differ: `MA` yields the type `allergy` and no category at all, `MC` yields neither, and each absence raises its own `TRANSFORM_CODE_UNMAPPED` rather than borrowing the other map's answer or a neighbouring code. `AL1-4` becomes `criticality` through the Table 0128 map, whose `MO` and `U` have no target: those leave `criticality` absent and flagged. `AllergyIntolerance.reaction.severity` is never populated from that component, because the guide offers it only as a local variation conditioned on a severity that was not used equivalently to criticality, which no v2 message states, and emitting both from one code would assert a clinical grading the message does not carry.
|
|
132
|
+
|
|
133
|
+
Whatever those maps do or do not translate, the code the sender wrote survives: the guide's `alternate-codes` extension carries the original `AL1-2` code in `v2-0127` on `category`, and the original `AL1-4` code in `v2-0128` on `criticality`, whether or not the translating map had a target for it. An unmapped category is therefore a `category` element carrying that extension and no code value, not an absent element.
|
|
134
|
+
|
|
135
|
+
Two conditions withhold the allergy rather than emit an unsafe one, each with a `TRANSFORM_ELEMENT_DROPPED` naming what could not be grounded: a message with no `Patient` to anchor `patient`, where the alternative would be a reference resolving to nothing; and an `AL1-3` that carries neither an allergen code nor allergen text, where the alternative would be an allergy that never says what it is to. A withheld occurrence keeps its completeness issue, so it is still reported rather than silently absent. `AL1-6` is read as `onsetDateTime` only for a message whose version identifier is readable and earlier than 2.7, the version at which the guide records the field as withdrawn; on 2.7 or later, or when the version cannot be read, it is dropped with a diagnostic rather than read as an onset the sender may not have meant. `IAM`, the guide's other allergy segment, is still not built and still reports `TRANSFORM_SEGMENT_NOT_EMITTED`.
|
|
136
|
+
|
|
137
|
+
No new issue code, no new dependency, and no change to any other message family: an `AL1`-free message produces exactly the bundle and the issues list it produced before.
|
|
138
|
+
|
|
139
|
+
- 673890c: Tell a consumer which segments a message carried that did not reach the returned bundle, instead of leaving the omission to be found by diffing the two by hand. Two new issue codes, `TRANSFORM_SEGMENT_NOT_EMITTED` and `TRANSFORM_SEGMENT_NO_IG_MAP`, are raised once per segment occurrence that contributed nothing to a resource the bundle contains, and they split a gap in this library from a gap in the standard so the two read differently.
|
|
140
|
+
|
|
141
|
+
Until now an issues list said nothing about an `AL1`, a `DG1` or an `IN1`, whether the bundle carried it or nobody had looked. The library already refuses to emit a confident wrong FHIR value at the datatype and resource levels; this is the same rule at the segment level, where silence had been reading as completeness. Nothing is transformed that was not transformed before: a flagged segment tells you what is missing, not what it said.
|
|
142
|
+
|
|
143
|
+
Reaching means contributing. An occurrence counts as reached only if the assembly took a value or the identity of it into a resource that is present in the returned bundle, so an `RXE` this library counts rather than assembles is reported, an orphan `OBX` is reported, and so is a resource the emit gate withheld. A segment whose values were all dropped downstream is not reported here: the resource built from it did reach the bundle, and those drops already have their own codes.
|
|
144
|
+
|
|
145
|
+
The location is the only message-derived thing either code carries, in exactly two shapes: `NAME[k]`, 1-based among the occurrences of that name, when the name passes the HL7 v2 segment-identifier shape; and `[#n]`, the segment's position in the message, when it does not, with no part of the name reproduced. The shape is applied by this library to the value the parser published, as a positive test, rather than by excluding a marker constant of the parser package: the peer range admits any parser version, and an exclusion test would promote an unrecognized value into a rendering the first time that constant moved. A position carrying neither a name nor any field content is the parser's ordinal placeholder for a blank line, and raises nothing wherever it sits, while still counting toward later ordinals so they match the line the sender wrote.
|
|
146
|
+
|
|
147
|
+
The split between the two codes comes from a hand transcription of the HL7 Version 2 to FHIR Implementation Guide's Segment Maps index at 1.0.0, published 2025-10-07 and retrieved 2026-08-22, exported as `IG_MAPPED_SEGMENT_NAMES` with its version, publication date, retrieval date and source URL so a later release of the guide can be told apart from a defect here. A name that could not be classified at all takes the no-map code as well, because a damaged identifier is never re-derived from the line; the core concepts documentation states that, and states that these locations are 1-based while the per-occurrence locations the library already emitted are 0-based and have not moved.
|
|
148
|
+
|
|
149
|
+
Nothing else changed, and it was measured rather than asserted. Both codes are informational and neither is fatal. Every resource, every value and every issue the library raised before is unchanged, and the new issues are appended after all of them: the Bundle and the full pre-existing issues list were captured for twenty-three inputs from the tree before the diagnostic existed, and both are asserted against those baselines rather than against the new code's own output.
|
|
150
|
+
|
|
151
|
+
- 76984bb: Declare, rather than merely leave, what the reverse (FHIR to v2) direction cannot supply. Two issue codes are added, additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed, and no emitted segment content changes.
|
|
152
|
+
|
|
153
|
+
`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` is raised once per v2-required field that ends up absent from an emitted segment because the FHIR resource carried no source this map could ground it from. Until now that absence was silent: a `Patient` with neither `identifier` nor `name` emitted an `ADT` whose `PID` carried neither PID-3 (Patient Identifier List) nor PID-5 (Patient Name), and an `Observation` with no `status` emitted an `ORU` whose `OBX` carried no OBX-11 (Observation Result Status), both with an empty `issues` array. The field is still left absent, exactly as before, because a placeholder written to satisfy v2 structure would be a fabricated clinical value; what changes is that the receiver is no longer the first to find out.
|
|
154
|
+
|
|
155
|
+
`TRANSFORM_NO_V2_MESSAGE_EMITTED` is raised when a conversion produces no message at all, because nothing in the resource grounded a single field of the target segment. That case previously returned `{ value: undefined, issues: [] }`, which a caller could not tell apart from a successful empty conversion. It is distinct from the refusals that name their own cause (an absent trigger, an unsupported resource type, a structurally malformed resource), each of which still returns its own code.
|
|
156
|
+
|
|
157
|
+
Both codes carry an `error` severity, a v2 location and a FHIR path, and no value, in keeping with the value-free diagnostic contract. Callers that branch on severity will now see these two, which is the intent: an emitted message missing a v2-required field is not conformant, and the diagnostic channel is where that is said.
|
|
158
|
+
|
|
159
|
+
- 64930ef: An order's timing segment now reaches the request it times: a `TQ1` accompanying an `RXO` becomes `MedicationRequest.dosageInstruction.timing`, and a `TQ1` accompanying a service order becomes `ServiceRequest.occurrenceTiming`. Until now a pharmacy order transformed to a drug, a dose range, a route and a dispense request with no schedule at all, so "give 5 mg every 4 hours for 3 days" and "give 5 mg once" arrived as the same resource.
|
|
160
|
+
|
|
161
|
+
**What a `TQ1` grounds.** `TQ1-3` (RPT) becomes the `Timing` over four components and only four: `RPT.1` Repeat Pattern Code to `Timing.code`, carried verbatim under the `v2-0335` CodeSystem when the published HL70335 map has a row for it; `RPT.5` Period Quantity to `repeat.period`, precision-exact; `RPT.6` Period Units to `repeat.periodUnit` when it already is one of FHIR's seven `UnitsOfTime` codes; and `RPT.8` Event to `repeat.when` when the published HL70528 map gives it a `v3-TimingEvent` target. `TQ1-7` and `TQ1-8` become `repeat.boundsPeriod.start` and `.end`, and value either one on its own and you get exactly that endpoint. `TQ1-10` is carried verbatim into `dosageInstruction.additionalInstruction.text` and `TQ1-11` into the resource's own `text` narrative, which are two different targets the guide names separately; neither is written to `dosageInstruction.text`, which the guide targets from nothing. Both rows are `TX`, a v2 primitive with no component structure, so both are read **whole**: a raw `^`, `&` or `~` inside a free-text instruction is content, not structure, and a taper written `2 tabs^then 1 tab` arrives with its second half intact instead of truncated at the first delimiter. That includes a delimiter the instruction ends on, which a field's canonical wire form drops as an empty position: on a free-text primitive it is the last character of a clinical instruction, so it is put back. It also includes a row that is nothing _but_ delimiters (`^`, `&~`, `^&~`), which carries the same characters as `^leading` with the letters removed and arrives as the text it is: "valued" on a primitive asks whether the wire put anything in the field, not whether some component position is non-empty, and the composite question would answer no for every one of them. The v2 formatting escapes a narrative carries are resolved on the way in (a delimiter escape to its literal character, a line-break command to a line break); a sequence with no defined rendering is preserved exactly as it stands rather than guessed at.
|
|
162
|
+
|
|
163
|
+
**Only a row that carries a value is written.** The HL7 explicit null, the two-character literal `""`, is the wire saying a field carries no value; reading a field whole makes that marker look like text, because the marker _is_ the field's characters. It is read here exactly as an absent field: no `additionalInstruction`, no narrative, no diagnostic about a field that dropped nothing, and a `TQ1` whose only content was a null is still reported as not reaching the bundle. A row that _did_ carry content is different even when nothing can be placed from it: content whose whole projection resolves away (a highlight-boundary pair with nothing between it is display markup, not text), and a `TQ1-11` of nothing but whitespace, which R4's `txt-2` refuses in a narrative ("the narrative SHALL have some non-whitespace content") and which no structural validator here models, each write no element and raise a value-free diagnostic naming the row. `TQ1-10` is not whitespace-tested, because its target is a plain `string` that R4 constrains no further and inventing a rule there would drop text the sender sent.
|
|
164
|
+
|
|
165
|
+
**A schedule is fully grounded or absent and flagged, and that is the whole safety claim.** A half-built timing reads to the receiving system as a complete dosing instruction, and a wrong frequency is a dosing error that no re-run undoes. So the whole `Timing` is withheld, with a value-free diagnostic naming what caused it, when: any RPT component outside those four is valued (`RPT.2` calendar alignment, `RPT.7` institution-specified time and `RPT.11` general timing specification have no target row at all; `RPT.3`/`RPT.4` need a day translation with no published table behind it; `RPT.9`/`RPT.10` need a rescale to minutes, which this library never performs; and the test is on whatever position the wire carried, so a component past the eleven the datatype defines is refused too rather than read as absent); a repeat-pattern, event, period or period-unit value cannot be carried without inventing a translation; any of the three bound-table components (`RPT.1`, `RPT.6`, `RPT.8`) declares a coding system that is not the table it is read against, because a site's local `AC` need not mean the published "before meal" concept that shares its spelling and `repeat.when`'s binding is required; a schedule-_narrowing_ field is valued (`TQ1-4`, `TQ1-5`, `TQ1-6`, `TQ1-12`, `TQ1-13`, `TQ1-14`, each of which makes the sent instruction narrower than the one that could be built without it); a bound is valued and yields no FHIR date-time, or the end precedes the start; a period quantity arrives without its period units, or period units arrive without a quantity, or the quantity is written with a minus sign (the test is on the sender's own lexical form, which is what the emitted value preserves, so a negative magnitude too small for a double to tell from zero is refused like any other), each of which produces a `Timing.repeat` that breaks a published R4 invariant (`tim-2` requires period units wherever a period exists, `tim-5` requires the period to be non-negative) and which no structural validator models, so it would otherwise reach a consumer as a grounded repeat with nothing to flag it; or one order carries two `TQ1` segments, or one `TQ1-3` carries two repeat patterns. A repetition of `TQ1-3` that carries no value is not one of them: `TQ1-3` is `0..-1`, so `Q4H~`, `~Q4H` and a second repetition the sender explicitly nulled all arrive in real traffic and each sends exactly one schedule, which is read from whichever repetition carries it. In every one of those cases the request is still emitted, with no timing element and no lone bounds period.
|
|
166
|
+
|
|
167
|
+
`TQ1-2` quantity and `TQ1-9` priority behave differently and deliberately: the guide maps them to the dose and the priority, which the `RXO`/`OBR` path already grounds, so they are flagged as dropped, neither field is touched, and the schedule the rest of the segment grounds is still emitted.
|
|
168
|
+
|
|
169
|
+
**`occurrence[x]` is a choice, so exactly one member is ever emitted.** A service order that would yield both an `occurrenceDateTime` from `OBR-6` and an `occurrenceTiming` from its `TQ1` emits the timing and flags the dropped `OBR-6`; a refused timing hands the choice back to `OBR-6` unchanged.
|
|
170
|
+
|
|
171
|
+
A `TQ1` that contributed a timing, an additional instruction or a narrative is no longer reported as not reaching the bundle; one that was read and refused still is, because it did not. No issue code is added: every diagnostic here is an existing member of `ISSUE_CODES`, and an order carrying no `TQ1` produces a byte-identical bundle and an identical issue list to before, measured against a captured baseline rather than asserted.
|
|
172
|
+
|
|
173
|
+
- 13750f1: Consume `@cosyte/hl7` from the registry at `0.0.10` instead of a vendored tarball at `0.0.1`, and prove by measurement that nothing this package emits moved with it. No emitted FHIR value changes, no diagnostic changes, no issue code is added, renamed or removed, and no mapping is touched: `src/` is byte-for-byte unchanged by this release.
|
|
174
|
+
|
|
175
|
+
The parser was nine published versions behind, pinned to a `pnpm pack` archive committed into the repository, which meant the version the tests exercised was watched by no dependency route and could only move by hand. It is now a plain devDependency resolved through the lockfile, the archive is deleted, and no copy of the library source remains in the tree. `@cosyte/fhir` is unaffected and stays vendored, for the one reason it always was: the registry does not have it.
|
|
176
|
+
|
|
177
|
+
Because this package sits in a PHI dataflow and its promise is never a confident wrong FHIR value, a green suite was not accepted as evidence on its own. Every HL7 v2 fixture the repository carries was collected mechanically into a frozen corpus, four synthetic inputs were authored for the malformed, truncated, empty and wrong-version classes, and all of it was transformed once on each side of the bump. The compared surface, meaning the FHIR output plus every diagnostic in emission order, is byte-identical across all 128 members. Both captures are committed beside the record so the comparison can be rerun rather than believed.
|
|
178
|
+
|
|
179
|
+
Two suites are added and none is removed or relaxed. One asserts the fail-safe rule class by class for malformed input, and sweeps every diagnostic the whole corpus raises to confirm each carries a registry-static message at a positional locator and no field content. The other records, as a call that compiles and runs, two upstream capabilities whose earlier absence shaped this package: an ADT message builder and a typed composite encoder both exist now. Neither is adopted here. The visit-carrying `Patient` plus `Encounter` output stays deferred, and the reverse path keeps building its fields by hand, because a refresh whose whole claim is that nothing moved cannot also change what is emitted.
|
|
180
|
+
|
|
3
181
|
## 0.0.9
|
|
4
182
|
|
|
5
183
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -15,7 +15,18 @@ parsers, it is a **consumer**: it takes already-parsed [`@cosyte/hl7`](https://g
|
|
|
15
15
|
composites and produces validated [`@cosyte/fhir`](https://github.com/cosyte/fhir) model nodes,
|
|
16
16
|
grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fhir.uv.v2mappings`).
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
"Validated" there means a small internal required-element check before a resource ships, which is a
|
|
19
|
+
floor this library wrote for itself and not a statement about FHIR conformance. How far the output
|
|
20
|
+
actually conforms is measured separately, against pinned FHIR R4 4.0.1 definitions and the pinned
|
|
21
|
+
`hl7.fhir.us.core` version 9.0.0 profiles, over the guide's own published test messages, and the
|
|
22
|
+
outcome is published in
|
|
23
|
+
[`documentation/conformance/report.md`](documentation/conformance/report.md). Read it before relying
|
|
24
|
+
on the word "validated": today it says none of the seven published test messages produces a Bundle
|
|
25
|
+
that is clean against R4 plus those profiles, and it lists every finding. The summary is under
|
|
26
|
+
[Conformance: measured, not asserted](#conformance-measured-not-asserted).
|
|
27
|
+
|
|
28
|
+
> **Status:** `0.1`, published to npm. While the package is below 1.0, a breaking change ships in a
|
|
29
|
+
> minor version and is called out in the changelog. This release ships the
|
|
19
30
|
> six safety-critical datatype converters and the value-free diagnostic channel, the
|
|
20
31
|
> message-level assembly, HL7 v2 **ADT → FHIR Patient + Encounter**, the **ORU^R01 → FHIR
|
|
21
32
|
> DiagnosticReport + Observation** results graph, the order-entry graph (**ORM_O01 /
|
|
@@ -24,8 +35,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh
|
|
|
24
35
|
> via `toFhir(msg)`, and **terminology value translation** of coded fields: route/site,
|
|
25
36
|
> appointment type, order priority, and substitution are now value-translated through their IG
|
|
26
37
|
> `mappedVia` ConceptMaps via `toFhirCodeableConceptVia`, fail-safe on any code the IG leaves unmapped.
|
|
27
|
-
> The v2→FHIR direction is feature-complete for the IG-covered message set
|
|
28
|
-
>
|
|
38
|
+
> The v2→FHIR direction is feature-complete for the IG-covered message set. It also ships a
|
|
39
|
+
> **narrow reverse path**, FHIR → v2: `toV2Patient(patient, trigger)` and
|
|
40
|
+
> `toV2Observation(observation, trigger)` emit a complete v2 message carrying a `PID` or an `OBX`.
|
|
41
|
+
> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented.
|
|
29
42
|
|
|
30
43
|
## Install
|
|
31
44
|
|
|
@@ -34,15 +47,16 @@ npm install @cosyte/transform @cosyte/hl7 @cosyte/fhir
|
|
|
34
47
|
```
|
|
35
48
|
|
|
36
49
|
`@cosyte/hl7` and `@cosyte/fhir` are **peer dependencies**: the transform maps between the models
|
|
37
|
-
they own
|
|
50
|
+
they own, so you install them beside it and parse with `@cosyte/hl7` before you transform. Its own
|
|
51
|
+
third-party runtime dependencies are **zero**.
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this package from source or a workspace link.
|
|
53
|
+
Requires Node `>=22.0.0`. The package ships dual ESM and CommonJS builds with type declarations for
|
|
54
|
+
both, so `import` and `require` both work.
|
|
42
55
|
|
|
43
56
|
## Convert a datatype
|
|
44
57
|
|
|
45
58
|
```ts
|
|
59
|
+
import { serializeResource } from "@cosyte/fhir";
|
|
46
60
|
import { toFhirHumanName } from "@cosyte/transform";
|
|
47
61
|
|
|
48
62
|
const { value, issues } = toFhirHumanName({
|
|
@@ -50,7 +64,10 @@ const { value, issues } = toFhirHumanName({
|
|
|
50
64
|
givenName: "Jane",
|
|
51
65
|
nameTypeCode: "L", // HL7 Table 0200 "Legal name" → FHIR name-use "official"
|
|
52
66
|
});
|
|
53
|
-
|
|
67
|
+
|
|
68
|
+
console.log(value === undefined ? "no value" : serializeResource(value));
|
|
69
|
+
// {"use":"official","family":"Public","given":["Jane"]}
|
|
70
|
+
console.log(issues); // [] (clean, fully mapped)
|
|
54
71
|
```
|
|
55
72
|
|
|
56
73
|
Each converter returns `{ value, issues }`: the FHIR datatype node it could faithfully produce, plus
|
|
@@ -84,8 +101,10 @@ of issues as a FHIR `OperationOutcome` with `toOperationOutcome(issues)`.
|
|
|
84
101
|
## Assemble a message
|
|
85
102
|
|
|
86
103
|
`toFhir(msg)` takes a parsed `@cosyte/hl7` **ADT** message and returns a FHIR R4 **message `Bundle`**:
|
|
87
|
-
a `MessageHeader`, then the `Patient` and `Encounter` (and `RelatedPerson
|
|
88
|
-
|
|
104
|
+
a `MessageHeader`, then the `Patient` and `Encounter` (and `RelatedPerson`, one
|
|
105
|
+
`AllergyIntolerance` per `AL1`, one `Condition` per `DG1`, one `Procedure` per `PR1` and one
|
|
106
|
+
`Coverage` per `IN1`) it describes, plus the value-free issues, each map grounded firsthand
|
|
107
|
+
on the IG's segment/table ConceptMaps.
|
|
89
108
|
|
|
90
109
|
```ts
|
|
91
110
|
import { parseHL7 } from "@cosyte/hl7";
|
|
@@ -97,25 +116,131 @@ const { bundle, issues } = toFhir(parseHL7(raw), {
|
|
|
97
116
|
// bundle.type === "message"; every reference resolves to a urn:uuid: fullUrl inside the bundle.
|
|
98
117
|
```
|
|
99
118
|
|
|
100
|
-
| Segment | FHIR resource
|
|
101
|
-
| ------- |
|
|
102
|
-
| MSH | `MessageHeader`
|
|
103
|
-
| PID | `Patient`
|
|
104
|
-
| PV1 | `Encounter`
|
|
105
|
-
| NK1 | `RelatedPerson`
|
|
119
|
+
| Segment | FHIR resource | key maps |
|
|
120
|
+
| ------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
121
|
+
| MSH | `MessageHeader` | MSH-9 → `eventCoding`; MSH-7/10 → `Bundle.timestamp`/`.identifier` |
|
|
122
|
+
| PID | `Patient` | PID-3/5/7/8/11 → `identifier`/`name`/`birthDate`/`gender`/`address` |
|
|
123
|
+
| PV1 | `Encounter` | PV1-2 → `class`/`status` (HL70004); PV1-19/44/45 → `identifier`/`period` |
|
|
124
|
+
| NK1 | `RelatedPerson` | NK1-2/3/4 → `name`/`relationship`/`address` |
|
|
125
|
+
| AL1 | `AllergyIntolerance` | AL1-2 → `category` + `type` (two maps); AL1-3/4/5 → `code`/`criticality`/`reaction` |
|
|
126
|
+
| DG1 | `Condition` | DG1-3/4 → `code`/`code.text`; DG1-5/19 → `onsetDateTime`/`recordedDate`; DG1-21 `D` → `verificationStatus` |
|
|
127
|
+
| PR1 | `Procedure` | PR1-3/4 → `code`/`code.text`; PR1-5 + PR1-7 → `performedDateTime` or `performedPeriod`; PR1-6/15 → `category`/`reasonCode` |
|
|
128
|
+
| IN1 | `Coverage` | IN1-2/15 → `identifier`/`type`; IN1-12/13 → `period`; IN1-4.1 → `payor.display`; IN1-10/49 → the subscriber-id extension |
|
|
129
|
+
|
|
130
|
+
An allergy is a field a downstream system may act on before prescribing, so the AL1 rows are read
|
|
131
|
+
strictly. `clinicalStatus` is the `active` the IG assigns (its constraint ait-1 needs one, and no AL1
|
|
132
|
+
component can say otherwise). AL1-2 resolves `category` and `type` against **two separate** IG maps
|
|
133
|
+
over Table 0127, independently, so a `MA` gets the type `allergy` and no category at all; whatever
|
|
134
|
+
either map translates, the original v2 code is carried beside it in the IG's `alternate-codes`
|
|
135
|
+
extension, so nothing the sender wrote is lost. `criticality` is the only severity target
|
|
136
|
+
(`AllergyIntolerance.reaction.severity` is a local variation the guide conditions on something no
|
|
137
|
+
message states), so an HL70128 code the map has no target for leaves it absent and flagged. An AL1
|
|
138
|
+
whose AL1-3 names no substance, or that arrives with no Patient to anchor, is withheld and declared
|
|
139
|
+
rather than emitted: an allergy to nothing, or one pointing at nobody, is worse than a reported gap.
|
|
140
|
+
AL1-6 is legacy input only, read as `onsetDateTime` for a message earlier than 2.7 (the version that
|
|
141
|
+
withdrew the field) and dropped with a diagnostic otherwise.
|
|
142
|
+
|
|
143
|
+
The diagnosis, procedure and coverage a message carries are read the same way. Each is wired to the
|
|
144
|
+
bundle Patient, and each `Condition` is referenced back from `Encounter.diagnosis` when the message
|
|
145
|
+
also described a visit; with no Patient to anchor them all three are withheld and declared, never
|
|
146
|
+
emitted pointing at nobody. Three refusals are worth knowing before you consume them.
|
|
147
|
+
**`Coverage.status` is never asserted**: the IN1 map publishes no row for it and R4's binding has no
|
|
148
|
+
neutral member, so the element ships value-absent with a `data-absent-reason` of `unknown` and a
|
|
149
|
+
diagnostic saying so. Read it as unknown, not as active coverage. **`Coverage.payor` names the
|
|
150
|
+
insurer without resolving it**: IN1-4.1 becomes a reference `display` and no literal reference,
|
|
151
|
+
because no `Organization` is built, and an IN1 that names no insurance company is withheld entirely,
|
|
152
|
+
since `payor` is required and nothing else grounds it. **`Procedure.status` is the `unknown` the
|
|
153
|
+
guide's own row directs** where the message context determines none; a `completed` this library was
|
|
154
|
+
never told is never selected. A DG1-21 of `D` sets `verificationStatus` to `entered-in-error`, the
|
|
155
|
+
one value the map assigns, and every other Table 0206 action code leaves the element absent and
|
|
156
|
+
flagged. Every row of the three maps whose target needs a `Practitioner`, `Location` or
|
|
157
|
+
`Organization`, or resolves by identifier rather than by bundle position, raises a diagnostic naming
|
|
158
|
+
the field and the path it did not build, so what was deferred is in the issues list rather than
|
|
159
|
+
missing without comment.
|
|
106
160
|
|
|
107
161
|
The fail-safe rule holds at the message level: an unmapped patient class, a naked timestamp, or an
|
|
108
162
|
unresolvable authority becomes a typed issue, never a fabricated value. A trigger the IG has no
|
|
109
163
|
**message** map for is assembled from the segment maps and flagged, never invented; every emitted
|
|
110
|
-
resource is
|
|
164
|
+
resource is checked against a small internal required-element schema before it ships, which is a
|
|
165
|
+
floor this library wrote for itself and not a statement about FHIR conformance. What conformance the
|
|
166
|
+
output actually reaches is measured separately, and published.
|
|
167
|
+
|
|
168
|
+
### Conformance: measured, not asserted
|
|
169
|
+
|
|
170
|
+
The guide publishes seven v2 test messages. This repository carries them, transforms every one of
|
|
171
|
+
them, validates every resource of the resulting Bundle against the pinned **FHIR R4 4.0.1**
|
|
172
|
+
definitions and the pinned **`hl7.fhir.us.core` version 9.0.0** profiles, and publishes the outcome
|
|
173
|
+
in [`documentation/conformance/report.md`](documentation/conformance/report.md), with the
|
|
174
|
+
machine-readable form beside it in `documentation/conformance/result.json`.
|
|
175
|
+
|
|
176
|
+
**Today that result is: none of the seven messages produces a Bundle with zero error-severity
|
|
177
|
+
results** against R4 plus those profiles. Against the base R4 4.0.1 definitions alone, without any
|
|
178
|
+
profile, six of the seven are clean; the exception is `SIU_S12`, whose `Appointment` is `booked` with
|
|
179
|
+
no start, which R4's own `app-3` invariant forbids. The profile findings are mostly one shape: US
|
|
180
|
+
Core requires elements the guide's segment maps publish no row for (`Encounter.type`,
|
|
181
|
+
`Coverage.relationship`, `Observation.category`, `DocumentReference.category`), and this library
|
|
182
|
+
leaves an ungrounded element absent rather than guessing at it. Every finding is listed in the
|
|
183
|
+
report, per message, per resource, with the element path and the profile version it came from.
|
|
184
|
+
|
|
185
|
+
Two things that result does **not** say. It says nothing about **mapping correctness**: a clean
|
|
186
|
+
Bundle is well formed and profile-conformant, not proof that the right v2 field reached the right
|
|
187
|
+
FHIR element. And it is not a full implementation-guide validator run: the report states which
|
|
188
|
+
classes of check were performed and which were not, external terminology resolution among the
|
|
189
|
+
latter. Both packages are carried in the repository and verified by digest on every run, so the
|
|
190
|
+
number is reproducible against a pin rather than against whatever was current; `pnpm run conformance`
|
|
191
|
+
regenerates it, and the test suite fails when the published result and a live run disagree.
|
|
192
|
+
|
|
193
|
+
**And silence is not completeness.** Every segment occurrence that contributed nothing to a resource
|
|
194
|
+
in the returned bundle raises one value-free issue naming it, so you can read the issues list instead
|
|
195
|
+
of diffing the message against the bundle: `TRANSFORM_SEGMENT_NOT_EMITTED` when the IG publishes a
|
|
196
|
+
segment map for that name (a gap here), `TRANSFORM_SEGMENT_NO_IG_MAP` when it publishes none, or when
|
|
197
|
+
the name could not be classified at all (a gap in the standard, or a damaged line). The location is
|
|
198
|
+
`DG1[2]` for a name that passes the v2 segment-identifier shape, 1-based among that name's
|
|
199
|
+
occurrences, and `[#4]` for one that does not, with no part of the name reproduced. Being read and
|
|
200
|
+
refused is not reaching: a counted `RXE`, an orphan `OBX` and a resource the emit gate withheld are
|
|
201
|
+
all reported. A flagged segment is still not transformed: you learn what is missing, not what it
|
|
202
|
+
said.
|
|
111
203
|
|
|
112
204
|
The same `toFhir(msg)` handles the other message families: **ORU^R01** → `DiagnosticReport` (OBR) +
|
|
113
|
-
`Observation` (OBX),
|
|
205
|
+
`Observation` (OBX) + `Specimen` (SPM, referenced from the report that scopes it, and withheld with
|
|
206
|
+
it rather than left orphaned), with each result's OBSERVATION-group `NTE` as `Observation.note` and
|
|
207
|
+
`OBX-2` selecting the guide's own `value[x]` (a `DR` period, an `NR` range, a `TM` time, an `NA`
|
|
208
|
+
sampled-data waveform, and a `Base64` `ED` payload carried into the guide's named `valueAttachment`
|
|
209
|
+
extension byte-for-byte, undecoded), and the order-entry graph, **ORM_O01 / OML_O21** ORC/OBR →
|
|
114
210
|
`ServiceRequest` and **RXO** (+ RXR route) → `MedicationRequest`, with `ServiceRequest.status`
|
|
115
211
|
grounded on the HL70119 → request-status ConceptMap and withheld when it cannot be grounded, and a
|
|
116
212
|
`MedicationRequest` whose IG-ungrounded status is the honest `unknown` rather than a guess. `RXE` has no
|
|
117
213
|
STU1 IG map and is flagged, never assembled.
|
|
118
214
|
|
|
215
|
+
An order's **`TQ1`** becomes the schedule that order carries: `dosageInstruction.timing` on the
|
|
216
|
+
`MedicationRequest`, `occurrenceTiming` on the `ServiceRequest` (and a group that would also yield an
|
|
217
|
+
`occurrenceDateTime` from `OBR-6` emits the timing alone and flags the dropped one, because
|
|
218
|
+
`occurrence[x]` is a choice). `TQ1-3` grounds `Timing.code` from the HL70335 repeat-pattern rows,
|
|
219
|
+
`repeat.period`, `repeat.periodUnit` and `repeat.when` from the HL70528 rows the guide gives a
|
|
220
|
+
`v3-TimingEvent` target; `TQ1-7` / `TQ1-8` give `repeat.boundsPeriod`; `TQ1-10` and `TQ1-11` are
|
|
221
|
+
carried verbatim to the two different targets the guide names, `dosageInstruction.additionalInstruction.text`
|
|
222
|
+
and the resource's own `text` narrative. Both are `TX`, a v2 primitive with no component structure,
|
|
223
|
+
so they are read **whole**: a raw `^`, `&` or `~` inside a free-text instruction is content, and a
|
|
224
|
+
taper written `2 tabs^then 1 tab` arrives with its second half intact rather than truncated at the
|
|
225
|
+
first delimiter, right down to a delimiter the instruction ends on, or a row that is nothing but
|
|
226
|
+
delimiters. Only a row that carries a value
|
|
227
|
+
is written, though: an HL7 explicit null (`""`) says the field carries none, so it reaches no
|
|
228
|
+
element and puts no marker in one, and no diagnostic either, because nothing was dropped. A row that
|
|
229
|
+
_did_ carry content the projection resolves away entirely (display markup alone), or a `TQ1-11` of
|
|
230
|
+
nothing but whitespace, which R4's `txt-2` forbids in a narrative, likewise writes nothing but is
|
|
231
|
+
**flagged**, so content that arrived is never mistaken for a field that was never sent.
|
|
232
|
+
**A schedule is fully grounded or absent and flagged**: a repeat component the guide gives no target
|
|
233
|
+
for (at any position, including one past the eleven the datatype defines), a code outside its
|
|
234
|
+
published table or sent under a coding system that is not that table, a value that would need a unit
|
|
235
|
+
rescale or an invented date, a field that narrows the schedule (`TQ1-4`, `TQ1-5`, `TQ1-6`, `TQ1-12`,
|
|
236
|
+
`TQ1-13`, `TQ1-14`), an unusable or inverted bound, a period quantity arriving
|
|
237
|
+
without its units or written with a minus sign (R4's `tim-2` and `tim-5` reject both), or a second
|
|
238
|
+
`TQ1` on one order or a second repeat pattern in one `TQ1-3` (a repetition that carries no value is
|
|
239
|
+
not one: `Q4H~` sends one schedule, and it is read wherever in the field it sat),
|
|
240
|
+
each withholds the whole `Timing` and raises a value-free diagnostic naming the cause. A
|
|
241
|
+
half-built timing would read to the receiving system as a complete dosing instruction, which is the
|
|
242
|
+
one outcome this library will not produce.
|
|
243
|
+
|
|
119
244
|
The thin single-trigger families complete the IG-covered message set: **VXU_V04** RXA (+
|
|
120
245
|
RXR route, ORC) → `Immunization` (status via the IG's three conditioned rows: a delete action →
|
|
121
246
|
`entered-in-error`, an unvalued RXA-20 → `completed`, else the HL70322 → event-status ConceptMap, with a
|
|
@@ -127,13 +252,63 @@ interpreted). Timezone-naked instants are dropped and flagged, never assigned a
|
|
|
127
252
|
|
|
128
253
|
`toFhirCodeableConceptVia(cwe, map)` applies a license-clean IG value ConceptMap
|
|
129
254
|
(transcribed and verified firsthand against the raw published IG JSON): **RXR** route/site
|
|
130
|
-
(HL70162/HL70550), **SCH-8** appointment type (HL70277), **RXO-9** substitution (HL70161),
|
|
131
|
-
priority (HL70485),
|
|
255
|
+
(HL70162/HL70550), **SCH-8** appointment type (HL70277), **RXO-9** substitution (HL70161), **OBR-5**
|
|
256
|
+
priority (HL70485), and the five **AL1** allergy maps (HL70127 to category and to type, HL70128 to
|
|
257
|
+
criticality, and the two original-code identity maps behind the alternate-codes extension),
|
|
258
|
+
translating the source table code to its FHIR target coding, additively (the raw
|
|
132
259
|
coding is preserved alongside the derived one). It is fail-safe by refusal: a code the IG map leaves in
|
|
133
260
|
its `(unmapped)` group is flagged, never coerced to a neighbour. Fields whose IG target is **SNOMED CT**
|
|
134
261
|
(RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the
|
|
135
262
|
IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented.
|
|
136
263
|
|
|
264
|
+
## Emit v2 back out, narrowly
|
|
265
|
+
|
|
266
|
+
Two entry points go the other way, FHIR → v2. Each takes the FHIR resource **plus the v2 trigger you
|
|
267
|
+
want the message to carry**, and returns the same `{ value, issues }` envelope, where `value` is a
|
|
268
|
+
complete `@cosyte/hl7` message:
|
|
269
|
+
|
|
270
|
+
| Function | in | out |
|
|
271
|
+
| --------------------------------------- | ------------------ | ------------------------------------------ |
|
|
272
|
+
| `toV2Patient(patient, trigger)` | FHIR `Patient` | a v2 `ADT^<trigger>` message with a `PID` |
|
|
273
|
+
| `toV2Observation(observation, trigger)` | FHIR `Observation` | a v2 `ORU^<trigger>` message with an `OBX` |
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
import { parseResource } from "@cosyte/fhir";
|
|
277
|
+
import { toV2Patient } from "@cosyte/transform";
|
|
278
|
+
|
|
279
|
+
const { resource } = parseResource(patientJson);
|
|
280
|
+
const { value, issues } = toV2Patient(resource, "A28", {
|
|
281
|
+
assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" },
|
|
282
|
+
envelope: { sendingApp: "EHR", sendingFacility: "MAIN" },
|
|
283
|
+
});
|
|
284
|
+
// value.toString() -> "MSH|^~\\&|EHR|MAIN|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**The trigger is required and is never inferred.** No FHIR resource carries an HL7 v2 message
|
|
288
|
+
trigger, so there is nothing to derive one from: supply it, or the call returns no message and one
|
|
289
|
+
`TRANSFORM_MISSING_TRIGGER` diagnostic, without building anything.
|
|
290
|
+
|
|
291
|
+
**This direction is lossy by design, and it is not a round-trip.** The published mapping guide runs
|
|
292
|
+
v2 → FHIR, and several of its rows are many-to-one, so their inverse is ambiguous and is **refused**:
|
|
293
|
+
`gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and
|
|
294
|
+
`Observation.status` `entered-in-error` each leave their v2 field absent with a
|
|
295
|
+
`TRANSFORM_CODE_NOT_INVERTIBLE` diagnostic rather than picking one of the v2 codes that could have
|
|
296
|
+
produced them. An element with no v2 field in this narrow map is flagged
|
|
297
|
+
(`TRANSFORM_NO_V2_TARGET`), a value v2 cannot carry unchanged is flagged and left out
|
|
298
|
+
(`TRANSFORM_VALUE_NOT_REPRESENTABLE`), and a coding system with no v2 mnemonic is flagged rather than
|
|
299
|
+
written under a borrowed table (`TRANSFORM_CODE_SYSTEM_NOT_V2`). Nothing here reconstructs the
|
|
300
|
+
message a resource came from, and nothing claims to.
|
|
301
|
+
|
|
302
|
+
**What v2 requires but your resource does not carry is left absent, and said out loud.** A `PID`
|
|
303
|
+
needs PID-3 (Patient Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation
|
|
304
|
+
Result Status). A resource that gives no source for one of them still gets a message with that field
|
|
305
|
+
absent, never a placeholder invented to satisfy v2 structure, and one
|
|
306
|
+
`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` diagnostic per field, carrying the v2 location and the FHIR path
|
|
307
|
+
it would have come from. A resource that grounds no field of the target segment at all returns no
|
|
308
|
+
message and one `TRANSFORM_NO_V2_MESSAGE_EMITTED`, so an empty-handed conversion is never mistaken
|
|
309
|
+
for a successful one. Both are `error` severity: an emitted message missing a field v2 requires is
|
|
310
|
+
not conformant, and this is where you find that out rather than at the receiver.
|
|
311
|
+
|
|
137
312
|
## License
|
|
138
313
|
|
|
139
314
|
MIT © Cosyte
|