@cosyte/hl7 0.1.1 → 0.1.3
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 +14 -0
- package/LICENSE +1 -1
- package/README.md +40 -1195
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +20 -15
package/README.md
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
[](https://www.npmjs.com/package/@cosyte/hl7)
|
|
13
13
|
[](https://github.com/cosyte/hl7/actions/workflows/ci.yml)
|
|
14
|
-
[](
|
|
15
|
-
[](https://nodejs.org)
|
|
14
|
+
[](https://github.com/cosyte/hl7/blob/main/LICENSE)
|
|
15
|
+
[](https://nodejs.org)
|
|
16
16
|
|
|
17
|
-
A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript.
|
|
17
|
+
A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript. Optimized for the 10% of HL7 you actually use, with the other 90% still one accessor away when you need it.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -24,15 +24,9 @@ A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript
|
|
|
24
24
|
- [Status](#status)
|
|
25
25
|
- [Install](#install)
|
|
26
26
|
- [Usage](#usage)
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
30
|
-
- [Access patterns](#access-patterns)
|
|
31
|
-
- [Cookbook](#cookbook)
|
|
32
|
-
- [Profiles](#profiles)
|
|
33
|
-
- [Real-World Tolerance](#real-world-tolerance)
|
|
34
|
-
- [Error Handling](#error-handling)
|
|
35
|
-
- [Roadmap](#roadmap)
|
|
27
|
+
- [Documentation](#documentation)
|
|
28
|
+
- [Support](#support)
|
|
29
|
+
- [Security](#security)
|
|
36
30
|
- [Contributing](#contributing)
|
|
37
31
|
- [Trademarks](#trademarks)
|
|
38
32
|
- [License](#license)
|
|
@@ -47,13 +41,20 @@ HL7 v2 is the format hospital systems actually speak, and reading one field out
|
|
|
47
41
|
|
|
48
42
|
## Status
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
The 0.1 line. The public API is settled and safe to depend on: the exported functions, the message and helper surfaces, and the 20 stable warning codes are what the version claims, and renaming a warning code counts as a breaking change here.
|
|
51
45
|
|
|
52
|
-
Nothing is queued as forthcoming
|
|
46
|
+
Nothing is queued as forthcoming. `parseStream` parses multi-GB batch files incrementally and yields the messages as an async iterable, and `emitMessageSchema` produces JSON Schema and Zod descriptions of what `toJSON()` returns. Both are exported, so both are covered by the stability claim above.
|
|
53
47
|
|
|
54
|
-
The opt-in structural validator ships too: `validateMessageStructure(msg)` enforces segment ordering and cardinality against HL7's published message structures. See [Check a message against HL7's published shape](#check-a-message-against-hl7s-published-shape-validatemessagestructure).
|
|
48
|
+
The opt-in structural validator ships too: `validateMessageStructure(msg)` enforces segment ordering and cardinality against HL7's published message structures. See [Check a message against HL7's published shape](https://github.com/cosyte/hl7/blob/main/docs-content/cookbook.md#check-a-message-against-hl7s-published-shape-validatemessagestructure).
|
|
55
49
|
|
|
56
|
-
Typed message overlays ship and are covered by the stability claim: `msg.is("ADT^A01")` answers from the message type the parser extracted and narrows the message for the compiler, so the accessors after the check are scoped to the segments that message type's published structure requires. See [Narrow a message to its type](#narrow-a-message-to-its-type).
|
|
50
|
+
Typed message overlays ship and are covered by the stability claim: `msg.is("ADT^A01")` answers from the message type the parser extracted and narrows the message for the compiler, so the accessors after the check are scoped to the segments that message type's published structure requires. See [Narrow a message to its type](https://github.com/cosyte/hl7/blob/main/docs-content/cookbook.md#narrow-a-message-to-its-type).
|
|
51
|
+
|
|
52
|
+
Permanently out of scope:
|
|
53
|
+
|
|
54
|
+
- **Network transport**: sending and receiving over MLLP is [`@cosyte/mllp`](https://github.com/cosyte/mllp)'s job; this library is parser-only and only strips MLLP framing on read.
|
|
55
|
+
- **HL7 v3 and CDA**: different spec family entirely; not in our plans.
|
|
56
|
+
- **FHIR conversion**: [`@cosyte/transform`](https://github.com/cosyte/transform) converts a parsed message to FHIR R4; this library does not.
|
|
57
|
+
- **Exhaustive coded-value validation**: we validate structure, not every HL7 table. Integrate a domain validator (e.g. your LIS's internal code registry) for that.
|
|
57
58
|
|
|
58
59
|
---
|
|
59
60
|
|
|
@@ -64,7 +65,7 @@ Typed message overlays ship and are covered by the stability claim: `msg.is("ADT
|
|
|
64
65
|
pnpm add @cosyte/hl7
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
Requires Node `>=22
|
|
68
|
+
Requires Node.js 22 or later, tested on 22, 24, and 26 (`engines.node` is `>=22.0.0`). The package ships dual ESM and CommonJS builds with type declarations for both, so `import` and `require` both work and neither needs a compatibility shim. There are zero runtime dependencies: the Node standard library is all it loads.
|
|
68
69
|
|
|
69
70
|
---
|
|
70
71
|
|
|
@@ -107,1199 +108,45 @@ The test suite reads that block out of this README, runs it against the package,
|
|
|
107
108
|
|
|
108
109
|
Note `precision`: a birth date is a day, not an instant, and every datetime is a fidelity `TS` that keeps the precision and timezone it arrived with rather than guessing a `Date`.
|
|
109
110
|
|
|
110
|
-
That is the whole pitch: no config, no schema upload, no spec lookup. The parser accepts vendor-quirky input by default, strips MLLP framing if it's there,
|
|
111
|
-
|
|
112
|
-
---
|
|
113
|
-
|
|
114
|
-
## PHI and safety
|
|
115
|
-
|
|
116
|
-
HL7 v2 messages carry patient data, so what this library does with the bytes you hand it is part of its contract.
|
|
117
|
-
|
|
118
|
-
**It does not log.** There is no logger, no debug channel and no `console` call anywhere in the library: diagnostics are returned to you, never printed. Tolerated deviations come back as `Hl7ParseWarning` values carrying a stable code, a bounded message and a position (`segmentIndex`, `fieldIndex`), never the field value that triggered them, so a warning is safe to log as-is.
|
|
119
|
-
|
|
120
|
-
**One field is an exception, and it is deliberate.** `Hl7ParseError.snippet` carries up to 40 characters of your input verbatim and unredacted so a fatal parse failure is actionable. It is the only field that echoes input without filtering, the library does not redact it, and it is the first thing to strip if your compliance posture requires it. `Hl7ParseError.message` is bounded rather than absolutely content-free: it echoes a token only where the token matches a shape the spec defines (a three-character segment identifier, a message type, a version, a known charset label) and withholds anything else.
|
|
121
|
-
|
|
122
|
-
**It retains nothing you do not hold.** Parsing returns an `Hl7Message` and the message content lives on that object and nowhere else. There is no cache, no pool and no module-level store of message data, so the content is released when you drop your reference to it. The one piece of process-wide state the library keeps is an optional registered default profile, which holds parsing configuration, not patient data.
|
|
123
|
-
|
|
124
|
-
**It writes nothing and sends nothing.** No file is opened, no directory is written and no network connection is made: the library imports only `node:buffer` and `node:crypto` from the standard library and has zero runtime dependencies. `parseStream` reads a stream you supply and opens nothing itself. `toString()`, `toJSON()` and `prettyPrint()` hand the message content back to you by design; where it goes next is your call, and `prettyPrint()` in particular is a debug view of real patient data, not a redacted one.
|
|
125
|
-
|
|
126
|
-
**What stays yours.** Transport security, storage, retention and audit, redaction before anything reaches a log or an error tracker, and access control are all the consuming application's. A parser cannot make a system HIPAA-compliant, and this one does not claim to.
|
|
127
|
-
|
|
128
|
-
---
|
|
129
|
-
|
|
130
|
-
## Features
|
|
131
|
-
|
|
132
|
-
- **One-line extraction**: `msg.patient.mrn`, `msg.meta.timestamp`, `msg.observations()`, and friends. No segment or field numbers to memorise.
|
|
133
|
-
- **Three access patterns**: named helpers, dot-paths (`msg.get("PID.5.1")`), or structural traversal (`msg.segments("OBX")[0].field(3)`). Pick the level of ceremony you need.
|
|
134
|
-
- **Typed message overlays**: `msg.is("ADT^A01")` answers at run time and narrows at compile time, so the message code and trigger event become literal types and `part` / `parts` are scoped to the segments that message type's published structure requires. No cast, no per-message-type import.
|
|
135
|
-
- **Opt-in published-structure validation**: `validateMessageStructure(msg)` checks segment order, occurrence counts and unnamed segments against HL7's own published message structures, derived offline from a vendored snapshot. Read-only, no new warning code, and nothing changes for a caller who does not ask.
|
|
136
|
-
- **Real-world tolerance, four-tier**: lenient default parses vendor-quirky messages; 20 stable warning codes flag what was tolerated; strict mode escalates every deviation for CI validators; only 4 truly-structural failures are fatal.
|
|
137
|
-
- **First-class profile system**: `defineProfile()` API, 8 built-in vendor profiles (Epic, Cerner, Meditech, athenahealth, generic lab, Visage 7 imaging/PACS, Philips Vue PACS, VA VistA Radiology/NucMed), plus a [publishable starter kit](./examples/profile-starter-kit/) you copy-and-ship.
|
|
138
|
-
- **Round-trip safe, byte-verbatim escapes**: `parse -> modify -> toString()` emits spec-clean HL7 regardless of input quirks (Postel's Law: liberal parser, conservative emitter), and a parsed field's escape sequences (`\H\`, `\X41\`, charset/vendor escapes) re-emit **byte-for-byte**. See [Escapes & round-trip](./docs-content/spec-notes-escapes.md).
|
|
139
|
-
- **Strict TypeScript, zero runtime deps**: ES2023, `noUncheckedIndexedAccess`, dual ESM + CJS, Node 22+. Every public function and class has JSDoc + `@example` that feeds your editor's IntelliSense.
|
|
140
|
-
- **Warnings carry stable codes + positional context**: react programmatically by `w.code`, with `segmentIndex`/`fieldIndex`/etc. attached.
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
## HL7 in 90 seconds
|
|
145
|
-
|
|
146
|
-
HL7 v2 is a pipe-delimited messaging format used across US healthcare. A message is a sequence of line-oriented **segments** (3-letter names like `MSH`, `PID`, `OBX`). Each segment carries **fields** separated by `|`; fields decompose into **components** (`^`) and **subcomponents** (`&`). Repeating fields use `~`.
|
|
147
|
-
|
|
148
|
-
A typical ADT message looks like this:
|
|
149
|
-
|
|
150
|
-
```
|
|
151
|
-
Message
|
|
152
|
-
├── MSH (header: sender, receiver, type, timestamp)
|
|
153
|
-
├── EVN (event details for ADT)
|
|
154
|
-
├── PID (patient identification: name, record number, DOB)
|
|
155
|
-
├── PV1 (visit: class, location, attending)
|
|
156
|
-
└── OBX×N (observations: repeats for labs, vitals)
|
|
157
|
-
|
|
158
|
-
Each segment = pipe-delimited (|) fields
|
|
159
|
-
Each field = caret-delimited (^) components
|
|
160
|
-
Each component = ampersand-delimited (&) subcomponents
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
That's enough HL7 to use this library productively. Everything else is in the spec, but you won't need it.
|
|
164
|
-
|
|
165
|
-
---
|
|
166
|
-
|
|
167
|
-
## Access patterns
|
|
168
|
-
|
|
169
|
-
Three ways to reach into a parsed message, each optimised for a different use case.
|
|
170
|
-
|
|
171
|
-
### Named helpers
|
|
172
|
-
|
|
173
|
-
Typed views over the common-case data. Zero HL7 literacy required.
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
177
|
-
|
|
178
|
-
const msg = parseHL7(raw);
|
|
179
|
-
|
|
180
|
-
console.log(msg.patient?.mrn); // "MRN12345"
|
|
181
|
-
console.log(msg.patient?.fullName); // "Jane Q. Smith"
|
|
182
|
-
console.log(msg.patient?.dateOfBirth?.precision); // "day": a birth date is not a full instant
|
|
183
|
-
console.log(msg.meta.type); // "ADT^A01"
|
|
184
|
-
console.log(msg.meta.timestamp?.raw); // "20250102153045" (MSH-7: fidelity TS)
|
|
185
|
-
console.log(msg.visit?.location); // PL composite: pointOfCare/room/bed
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Use these for the 90% case. Helpers return `undefined` (not throws) when the underlying segment is absent, so optional-chaining stays idiomatic.
|
|
189
|
-
|
|
190
|
-
### Dot-paths
|
|
191
|
-
|
|
192
|
-
String paths following HL7's own `SEG.field.component.subcomponent` convention. Indices are 1-based to match the spec.
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
196
|
-
|
|
197
|
-
const msg = parseHL7(raw);
|
|
198
|
-
|
|
199
|
-
console.log(msg.get("PID.5.1")); // family name -> "Smith"
|
|
200
|
-
console.log(msg.get("PID.5.2")); // given name -> "Jane"
|
|
201
|
-
console.log(msg.get("OBX[2].5")); // 2nd OBX, value field
|
|
202
|
-
console.log(msg.getAll("NK1")); // every next-of-kin segment
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
Reach for dot-paths when you want a specific field the helpers don't surface, or when you're scripting quick extractions without introducing typed imports.
|
|
206
|
-
|
|
207
|
-
### Structural traversal
|
|
208
|
-
|
|
209
|
-
Walk the segment/field/component/subcomponent tree directly. For advanced extractors, round-trip edits, and anything the other two patterns don't cover.
|
|
210
|
-
|
|
211
|
-
```ts
|
|
212
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
213
|
-
|
|
214
|
-
const msg = parseHL7(raw);
|
|
215
|
-
|
|
216
|
-
for (const obx of msg.segments("OBX")) {
|
|
217
|
-
const id = obx.field(3).component(1).value; // observation identifier
|
|
218
|
-
const val = obx.field(5).value; // observed value
|
|
219
|
-
console.log(`${id} = ${val}`);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
console.log(msg.allSegments().length); // total segments in the message
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Every level (segment / field / repetition / component / subcomponent) is addressable and immutable: mutation happens through explicit methods (see the cookbook).
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## Cookbook
|
|
230
|
-
|
|
231
|
-
Runnable recipes for the tasks developers hit most often. Every snippet imports from `@cosyte/hl7` and uses APIs exported from [`src/index.ts`](./src/index.ts). Keep them short; lean on the named-helper surface first.
|
|
232
|
-
|
|
233
|
-
### Patient demographics
|
|
234
|
-
|
|
235
|
-
Reach for the `msg.patient` helper for the name, record-number and date-of-birth trio that covers 95% of demographic extractions.
|
|
236
|
-
|
|
237
|
-
```ts
|
|
238
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
239
|
-
|
|
240
|
-
const msg = parseHL7(raw);
|
|
241
|
-
const p = msg.patient;
|
|
242
|
-
|
|
243
|
-
console.log(p?.mrn); // "MRN12345"
|
|
244
|
-
console.log(p?.fullName); // "Jane Q. Smith"
|
|
245
|
-
console.log(p?.dateOfBirth?.raw); // "19800115" (PID-7: fidelity TS at day precision)
|
|
246
|
-
console.log(p?.sex); // "F"
|
|
247
|
-
console.log(p?.address?.city); // XAD composite
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
`msg.patient` is `undefined` when the message has no `PID` segment. Use `?.` consistently. The `address` field is an `XAD` composite with `streetAddress`, `city`, `state`, `zip`, `country`. `dateOfBirth` (and every datetime) is a **fidelity `TS`**. See [Datetime precision & timezone fidelity](#datetime-precision--timezone-fidelity).
|
|
251
|
-
|
|
252
|
-
### Lab results
|
|
253
|
-
|
|
254
|
-
Iterate `msg.observations()` for the flat-list view of every `OBX` segment, regardless of whether they're grouped under an `OBR`.
|
|
255
|
-
|
|
256
|
-
```ts
|
|
257
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
258
|
-
|
|
259
|
-
const msg = parseHL7(raw);
|
|
260
|
-
|
|
261
|
-
for (const obs of msg.observations()) {
|
|
262
|
-
console.log(obs.identifier.text, obs.value, obs.units?.identifier);
|
|
263
|
-
// "Glucose" 120 "mg/dL"
|
|
264
|
-
}
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
Observations are discriminated by `valueType` (`"NM"` -> number, `"TS"`/`"DT"` -> fidelity `TS`, `"CWE"`/`"CE"` -> composite, everything else -> string). Use `msg.orders()` instead when you need OBR -> OBX grouping for lab order processing.
|
|
268
|
-
|
|
269
|
-
### Admit location
|
|
270
|
-
|
|
271
|
-
The `msg.visit` helper surfaces `PV1` data including the `PL` (Person Location) composite: ward / room / bed / facility in one go.
|
|
272
|
-
|
|
273
|
-
```ts
|
|
274
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
275
|
-
|
|
276
|
-
const msg = parseHL7(raw);
|
|
277
|
-
const loc = msg.visit?.location;
|
|
278
|
-
|
|
279
|
-
console.log(loc?.pointOfCare); // "ICU"
|
|
280
|
-
console.log(loc?.room); // "101"
|
|
281
|
-
console.log(loc?.bed); // "A"
|
|
282
|
-
console.log(loc?.facility?.namespaceId); // "MAIN"
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
`msg.visit` is `undefined` when the message has no `PV1` segment (most `ORU`/`ORM` messages). The `attendingDoctor` and `referringDoctor` fields are `XCN` composites.
|
|
286
|
-
|
|
287
|
-
### Modify and reserialize
|
|
288
|
-
|
|
289
|
-
`setField` mutates the positional tree in-place; `toString()` emits spec-clean HL7 (Postel's Law). See [`examples/modify-and-resend.ts`](./examples/modify-and-resend.ts) for the end-to-end script.
|
|
290
|
-
|
|
291
|
-
```ts
|
|
292
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
293
|
-
|
|
294
|
-
const msg = parseHL7(raw);
|
|
295
|
-
|
|
296
|
-
console.log(msg.get("PV1.3.1")); // original ward
|
|
297
|
-
msg.setField("PV1.3.1", "NEW-WARD");
|
|
298
|
-
console.log(msg.get("PV1.3.1")); // "NEW-WARD"
|
|
299
|
-
|
|
300
|
-
const outbound = msg.toString(); // spec-clean HL7 wire format
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
`setField` takes the same dot-path syntax as `get`; `addSegment(name, fields)` and `removeSegment(...)` round out the mutation surface. The emitter always uses canonical delimiters (`|`, `^`, `~`, `\`, `&`) regardless of what the input used.
|
|
304
|
-
|
|
305
|
-
### Allergies
|
|
306
|
-
|
|
307
|
-
`msg.allergies()` walks every `AL1` and every `IAM` segment and returns a typed list, one entry per segment in document order. An ADT^A60 carries its allergies in `IAM` and no `AL1`, so it is read too. Each entry names the segment it came from in `source`.
|
|
308
|
-
|
|
309
|
-
```ts
|
|
310
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
311
|
-
|
|
312
|
-
const msg = parseHL7(raw);
|
|
313
|
-
|
|
314
|
-
for (const al of msg.allergies()) {
|
|
315
|
-
console.log(al.source, al.code?.text, al.severity, al.reaction);
|
|
316
|
-
// "IAM" "Penicillin" "SV" "Hives"
|
|
317
|
-
if (al.deleteRequested === true) {
|
|
318
|
-
// IAM-6 was "D": the sender asks you to delete the allergy it sent earlier
|
|
319
|
-
// under al.uniqueIdentifier?.entityIdentifier. Nothing was deleted for you.
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
Fields are parsed into their spec-typed shapes (`code` is a `CWE` composite, `onsetDate` is a fidelity `TS`). An `IAM` entry also carries `actionCode` (IAM-6 exactly as sent, absent when IAM-6 is empty rather than assumed to be `A`) and `uniqueIdentifier` (IAM-7 components 1 and 2).
|
|
325
|
-
|
|
326
|
-
What it does not do:
|
|
327
|
-
|
|
328
|
-
- It never applies an action. A `D` entry is still returned, marked `deleteRequested: true`; a `U` entry never replaces an earlier one; an `AL1` and an `IAM` for the same allergen are two entries. The library holds no allergy list to apply them to, so that is the receiver's call.
|
|
329
|
-
- It does not read the `IAR` (adverse reaction) or `NTE` segments under an `IAM`, nor IAM-1 or IAM-8 onward, and it reads IAM-2 and IAM-4 as component 1 and IAM-5 as its first repetition. Reach the rest with `msg.segments("IAM")`.
|
|
330
|
-
- A missing IAM-7 is left missing: IAM-3 is never used in its place.
|
|
331
|
-
|
|
332
|
-
The same helper family exists for next-of-kin (`msg.nextOfKin()`), diagnoses (`msg.diagnoses()`), insurance (`msg.insurance()`), medications (`msg.medications()`), and immunizations (`msg.immunizations()`). Each medication carries `orderControl`, the ORC-1 order control of the `ORC` that opened its order group (`NW`, `DC`, `HD` and so on) exactly as sent, never an interpreted state such as active, held or discontinued, and absent when no `ORC` opens the group or ORC-1 is empty.
|
|
333
|
-
|
|
334
|
-
Each immunization carries `administrationStatus`, read from that RXA's own RXA-21, RXA-5 and RXA-20: `completed` for a dose given (RXA-20 `CP` or `PA`), `not-done` for a refused or not-administered dose (`RE` or `NA`), `no-vaccine-administered` for a CVX `998` placeholder, `delete-requested` for an RXA-21 `D` asking to delete an administration sent earlier, and `undetermined` for anything not exactly mapped, never `completed`. The raw RXA-20 and RXA-21 codes ride beside it with the field and table that decided. It is reported, never applied: a `D` record is still returned and nothing is removed. See the [immunization status notes](./docs-content/spec-notes-immunization-status.md).
|
|
335
|
-
|
|
336
|
-
### Scheduling, documents & charges (SIU · MDM · DFT)
|
|
337
|
-
|
|
338
|
-
Three breadth helpers cover the scheduling, medical-record, and financial message families:
|
|
339
|
-
|
|
340
|
-
```ts
|
|
341
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
342
|
-
|
|
343
|
-
// SIU, appointments(): SCH id/status/timing + AIS/AIG/AIL/AIP resources
|
|
344
|
-
for (const appt of parseHL7(raw).appointments()) {
|
|
345
|
-
console.log(appt.fillerAppointmentId, appt.fillerStatusCode?.identifier); // status verbatim
|
|
346
|
-
const provider = appt.resources.find((r) => r.kind === "personnel");
|
|
347
|
-
console.log(provider?.person?.familyName);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// MDM, documents(): completion (TXA-17) and availability (TXA-19) are DISTINCT
|
|
351
|
-
for (const doc of parseHL7(raw).documents()) {
|
|
352
|
-
console.log(doc.completionStatus, doc.availabilityStatus); // e.g. "IP" (in progress) + "AV" (available)
|
|
353
|
-
// never conflated: a document can be AVAILABLE before it is AUTHENTICATED;
|
|
354
|
-
// reading a preliminary document as final is the harm this split prevents.
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// DFT, charges(): FT1 billing fields, amounts kept as canonical wire text (no money-as-float)
|
|
358
|
-
for (const charge of parseHL7(raw).charges()) {
|
|
359
|
-
console.log(charge.transactionType, charge.transactionCode?.identifier);
|
|
360
|
-
console.log(charge.amountExtended); // e.g. "150.00^USD": a string, never a number
|
|
361
|
-
}
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
These are **breadth** helpers: they surface the common trigger events' core fields, not a scheduling-workflow state machine, signature verification, or a claims/pricing engine (see the known limitations in the docs).
|
|
365
|
-
|
|
366
|
-
### Order & medication timing
|
|
367
|
-
|
|
368
|
-
Every `Order` (from `msg.orders()`) and `Medication` (from `msg.medications()`) carries a `timings` array: the `TQ1` segment (HL7 v2.5+) or the legacy embedded TQ in `ORC-7` / `RXE-1` (pre-v2.5).
|
|
369
|
-
|
|
370
|
-
```ts
|
|
371
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
372
|
-
|
|
373
|
-
const med = parseHL7(raw).medications()[0];
|
|
374
|
-
|
|
375
|
-
for (const t of med?.timings ?? []) {
|
|
376
|
-
console.log(t.source); // "TQ1" or "legacy"
|
|
377
|
-
console.log(t.repeatPattern?.code); // "Q6H": VERBATIM, never resolved to a schedule
|
|
378
|
-
console.log(t.repeatPattern?.kind); // "parametric" (provenance only)
|
|
379
|
-
console.log(t.repeatPattern?.interval); // { count: 6, unit: "H" }, the load-bearing integer
|
|
380
|
-
console.log(t.totalOccurrences); // 24, from TQ1-14 (not TQ1-11)
|
|
381
|
-
console.log(t.startDateTime?.raw, t.startDateTime?.precision);
|
|
382
|
-
}
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
The repeat pattern (Table 0335) is surfaced **verbatim**: hl7 never normalizes a sig, resolves `Q6H` to clock times, or maps it to a different frequency (reading `Q6H` as "daily", or losing a `BID`, changes the administered dose count). The `kind` flag (`parametric`/`named`/`unknown`) is provenance only and never drives a schedule; a parametric `Q<n><unit>` template's integer is never dropped. See `docs-content/spec-notes-timing.md` for the field map and non-goals.
|
|
386
|
-
|
|
387
|
-
### Patient merges and identity events
|
|
388
|
-
|
|
389
|
-
`msg.identityEvents()` recognizes the ADT identity-management trigger events: merges (A18/A34/A35/A36/A39/A40/A41/A42), moves (A43/A44/A45), identifier changes (A47/A49/A50/A51), link/unlink (A24/A37), and person add/update (A28/A31). It surfaces every party **labelled by role**. On a merge, move or change, the MRG segment carries the _prior_ (non-surviving) identifiers and the PID carries the _surviving_ ones; the direction is the spec constant `MRG_TO_PID` and is never inferred from content.
|
|
390
|
-
|
|
391
|
-
The recognized set is floored by the published message structures: every ADT trigger event whose structure requires an MRG segment is recognized, so the read side cannot fall behind the structures the parser validates against. `kind` stays meaningful across that floor: an identifier change (`"change"`) replaces one identifier on one record and is never reported as a `"merge"`, so a consumer that acts only on `kind === "merge"` never starts conflating records on an event it did not expect.
|
|
392
|
-
|
|
393
|
-
```ts
|
|
394
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
395
|
-
|
|
396
|
-
const msg = parseHL7(rawA40);
|
|
397
|
-
|
|
398
|
-
for (const ev of msg.identityEvents()) {
|
|
399
|
-
if (ev.kind === "merge" && ev.prior && ev.surviving && ev.warnings.length === 0) {
|
|
400
|
-
// retire ev.prior.identifiers in favour of ev.surviving.identifiers
|
|
401
|
-
console.log(ev.prior.identifiers[0]?.idNumber, "->", ev.surviving.identifiers[0]?.idNumber);
|
|
402
|
-
} else if (ev.warnings.length > 0) {
|
|
403
|
-
// incomplete MRG->PID pair (MERGE_MISSING_PRIOR_OR_SURVIVOR), route for review
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
A mis-applied merge conflates two patients or orphans data under a retired record number, so the helper is deliberately conservative: an incomplete pair (no MRG, an orphaned MRG, or a PID with no surviving identifier) surfaces whatever _is_ present plus a `MERGE_MISSING_PRIOR_OR_SURVIVOR` warning on the event: the MRG is never dropped, and the direction is never guessed. The MRG field map is version-scoped: the backward-compat single-ID fields (PID-2 / MRG-4, withdrawn as of HL7 v2.7) are not read when MSH-12 declares v2.7+. Note that `@cosyte/hl7` _surfaces_ the merge. Actually re-pointing stored data to the survivor is your integration engine's job.
|
|
409
|
-
|
|
410
|
-
### Write your first profile in 10 minutes
|
|
411
|
-
|
|
412
|
-
A profile is plain data: a name, optional extra `dateFormats`, and a `customSegments` map of Z-segment declarations. The record shape maps a caller-visible field name to its 1-indexed HL7 position.
|
|
413
|
-
|
|
414
|
-
```ts
|
|
415
|
-
import { defineProfile, parseHL7 } from "@cosyte/hl7";
|
|
416
|
-
|
|
417
|
-
const myProfile = defineProfile({
|
|
418
|
-
name: "myhospital",
|
|
419
|
-
description: "Custom Z-segments for our internal integration",
|
|
420
|
-
customSegments: {
|
|
421
|
-
ZAL: { fields: { allergyId: 1, severity: 2, verifiedAt: 3 } },
|
|
422
|
-
},
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
const msg = parseHL7(raw, myProfile);
|
|
426
|
-
const zal = msg.allSegments().find((s) => s.type === "ZAL");
|
|
427
|
-
console.log(zal?.get("severity")?.value); // "HIGH"
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
Pass the profile as the second argument to `parseHL7`; afterwards, `seg.get("fieldName")` resolves through the profile's field aliases. `UNKNOWN_SEGMENT` warnings for declared Z-segments are suppressed automatically.
|
|
431
|
-
|
|
432
|
-
### Extending a profile
|
|
433
|
-
|
|
434
|
-
`extends` layers one profile on top of another. Use it to add hospital-specific Z-segments on top of a vendor baseline.
|
|
435
|
-
|
|
436
|
-
```ts
|
|
437
|
-
import { defineProfile, profiles, parseHL7 } from "@cosyte/hl7";
|
|
438
|
-
|
|
439
|
-
const myEpic = defineProfile({
|
|
440
|
-
name: "my-epic",
|
|
441
|
-
extends: profiles.epic,
|
|
442
|
-
customSegments: {
|
|
443
|
-
ZPR: { fields: { providerId: 1, specialty: 2 } },
|
|
444
|
-
},
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
const msg = parseHL7(raw, myEpic);
|
|
448
|
-
console.log(msg.profile?.lineage); // ["epic", "my-epic"]
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
Date formats concatenate (deduped), `customSegments` deep-merge per key, `onWarning` handlers chain in lineage order. The parent's `describe()` surface is preserved so `myEpic.describe()` lists both layers.
|
|
452
|
-
|
|
453
|
-
### Composing profiles
|
|
454
|
-
|
|
455
|
-
Pass an array to `extends` to merge multiple parents. Useful when combining a vendor profile with a market-specific overlay (e.g. Epic + reference-lab conventions).
|
|
456
|
-
|
|
457
|
-
```ts
|
|
458
|
-
import { defineProfile, profiles, parseHL7 } from "@cosyte/hl7";
|
|
459
|
-
|
|
460
|
-
const combined = defineProfile({
|
|
461
|
-
name: "epic-plus-lab",
|
|
462
|
-
extends: [profiles.epic, profiles.genericLab],
|
|
463
|
-
dateFormats: ["YYYY-MM-DD HH:mm"],
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
const msg = parseHL7(raw, combined);
|
|
467
|
-
console.log(msg.profile?.lineage); // ["epic", "genericLab", "epic-plus-lab"]
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
Conflicts on scalar keys (later wins), arrays concatenate+dedupe, `customSegments` maps merge. See [Merge semantics](#merge-semantics) in the Profiles section for the full rules.
|
|
471
|
-
|
|
472
|
-
### Publishing a profile package
|
|
473
|
-
|
|
474
|
-
Real production specs live at the integration level: specific EHR instances, reference labs, HIEs. The [profile starter kit](./examples/profile-starter-kit/) is a copy-and-customise template: it ships publishable as-is, with placeholders you replace with your org/profile names.
|
|
475
|
-
|
|
476
|
-
```bash
|
|
477
|
-
cp -r examples/profile-starter-kit my-profile && cd my-profile
|
|
478
|
-
# Find/replace {{YOUR_ORG}} and {{PROFILE_NAME}}, then:
|
|
479
|
-
pnpm install
|
|
480
|
-
pnpm test
|
|
481
|
-
pnpm build
|
|
482
|
-
pnpm publish --access public
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
See [`examples/profile-starter-kit/CUSTOMIZING.md`](./examples/profile-starter-kit/CUSTOMIZING.md) for the 5-step walkthrough (rename, swap base profile, define Z-segments, write fixtures, publish). The kit includes CI + publish workflows, a sample profile that demonstrates every feature, and zero-threshold Vitest so you ship green-or-red without fighting coverage gates.
|
|
486
|
-
|
|
487
|
-
### Default profile
|
|
488
|
-
|
|
489
|
-
`setDefaultProfile` registers a profile for every subsequent `parseHL7(...)` call in the current Node process. Convenient when every message in your pipeline comes from the same sender; explicit per-call passing is usually clearer.
|
|
490
|
-
|
|
491
|
-
```ts
|
|
492
|
-
import { parseHL7, profiles, setDefaultProfile, getDefaultProfile } from "@cosyte/hl7";
|
|
493
|
-
|
|
494
|
-
setDefaultProfile(profiles.epic);
|
|
495
|
-
console.log(getDefaultProfile()?.name); // "epic"
|
|
496
|
-
|
|
497
|
-
const msg = parseHL7(raw); // uses epic implicitly
|
|
498
|
-
const bare = parseHL7(raw, { profile: null }); // opt out for this call
|
|
499
|
-
|
|
500
|
-
setDefaultProfile(null); // reset
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
The default is scoped to the current Node process: not shared across workers, not serialisable. Opt out for a specific parse with `{ profile: null }` in the options bag.
|
|
504
|
-
|
|
505
|
-
### Datetime precision & timezone fidelity
|
|
506
|
-
|
|
507
|
-
Every HL7 datetime (TS/DTM) is surfaced as a **fidelity `TS`**: the raw string plus its typed parts,
|
|
508
|
-
with the stated **precision** and **timezone** preserved. It is deliberately **not** an eager JS `Date`:
|
|
509
|
-
a day-only birth date coerced to a UTC-midnight instant reads as the _previous day_ in a negative-offset
|
|
510
|
-
zone. The parser preserves what the sender wrote and lets you decide how to localize.
|
|
511
|
-
|
|
512
|
-
```ts
|
|
513
|
-
import { parseHL7, dtmToDate } from "@cosyte/hl7";
|
|
514
|
-
|
|
515
|
-
const dob = parseHL7(raw).patient?.dateOfBirth;
|
|
516
|
-
console.log(dob?.raw); // "19880705"
|
|
517
|
-
console.log(dob?.precision); // "day": never zero-filled to a full timestamp
|
|
518
|
-
console.log(dob?.year, dob?.month, dob?.day); // 1988 7 5 (month is 1-based, spec-native)
|
|
519
|
-
console.log(dob?.hasTimezone); // false: a missing offset is FLAGGED, never assumed to be UTC
|
|
520
|
-
|
|
521
|
-
// An absolute instant is opt-in and honest. It refuses to guess a zone:
|
|
522
|
-
console.log(dtmToDate(dob!)); // undefined (no offset, no assumption)
|
|
523
|
-
console.log(dtmToDate(dob!, { assumeOffsetMinutes: 0 })); // 1988-07-05T00:00:00.000Z (you chose UTC)
|
|
524
|
-
|
|
525
|
-
// A value that carries its own offset resolves exactly:
|
|
526
|
-
const ts = parseHL7(raw).meta.timestamp; // e.g. "20250102153045-0500"
|
|
527
|
-
console.log(ts?.offsetMinutes); // -300
|
|
528
|
-
console.log(dtmToDate(ts!)?.toISOString()); // "2025-01-02T20:30:45.000Z"
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
hl7 preserves precision + timezone **fidelity**; it does **not** localize, convert, or do arithmetic on
|
|
532
|
-
timestamps. A consumer needing an absolute instant applies the sender's zone via `assumeOffsetMinutes`.
|
|
533
|
-
Use `parseDtm` / `formatDtm` / `dtmToDate` directly on a raw string when you're outside the message model.
|
|
534
|
-
|
|
535
|
-
#### Converting a datetime: `toObject`, `toISO`, `toDate`
|
|
536
|
-
|
|
537
|
-
Three conversion helpers sit on top of the fidelity `TS`. Every `@cosyte` parser exposes these same
|
|
538
|
-
three names with the same semantics, so you learn the conversion story once and it holds across the
|
|
539
|
-
suite.
|
|
540
|
-
|
|
541
|
-
| function | returns | gives you |
|
|
542
|
-
| ------------------------- | ---------------------------------- | ------------------------------------------------------ |
|
|
543
|
-
| `toObject(value)` | frozen `DateParts`, or `undefined` | the stated calendar components as a plain object |
|
|
544
|
-
| `toISO(value)` | `string`, or `undefined` | ISO-8601, truncated to the stated precision |
|
|
545
|
-
| `toDate(value, options?)` | `Date`, or `undefined` | an absolute instant, only when the zone is determinate |
|
|
546
|
-
|
|
547
|
-
```ts
|
|
548
|
-
import { parseDtm, toObject, toISO } from "@cosyte/hl7";
|
|
549
|
-
|
|
550
|
-
toObject(parseDtm("19880705"));
|
|
551
|
-
// { year: 1988, month: 7, day: 5 }: exactly the stated components, nothing zero-filled
|
|
552
|
-
|
|
553
|
-
toISO(parseDtm("19880705")); // "1988-07-05": truncated to the stated precision, no fabricated Z
|
|
554
|
-
toISO(parseDtm("20250102153045.5-0500")); // "2025-01-02T15:30:45.5-05:00": digits verbatim
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
`toObject` carries only the components the value stated, so `Object.keys()` recovers its precision and
|
|
558
|
-
nothing is invented: no `raw`, no `valid`, no `precision`. `month` is spec-native 1 to 12, which is
|
|
559
|
-
exactly what `Temporal.PlainDateTime.from({...})` and luxon's `DateTime.fromObject({...})` accept:
|
|
560
|
-
delete `offsetMinutes` and hand the rest over with no key rename and no value adjustment. Neither
|
|
561
|
-
library is a dependency here, and neither needs to be. `millisecond` is the first three fractional
|
|
562
|
-
digits taken verbatim and right-padded, so `.5` is 500 ms and `.0500` is 50 ms, never a float
|
|
563
|
-
multiplication that loses the last digit. `offsetMinutes` appears if and only if the value carried an
|
|
564
|
-
explicit offset, and is never synthesised from the host machine's zone.
|
|
565
|
-
|
|
566
|
-
**`toDate` never guesses a zone.** An offset-less value returns `undefined` unless you say which zone
|
|
567
|
-
it was written in:
|
|
568
|
-
|
|
569
|
-
```ts
|
|
570
|
-
import { parseDtm, toDate } from "@cosyte/hl7";
|
|
571
|
-
|
|
572
|
-
toDate(parseDtm("20250102")); // undefined: no offset stated, and none assumed
|
|
573
|
-
toDate(parseDtm("20250102"), { assumeOffsetMinutes: 0 }); // 2025-01-02T00:00:00.000Z: you chose UTC
|
|
574
|
-
toDate(parseDtm("20250102"), { assumeOffsetMinutes: -300 }); // 2025-01-02T05:00:00.000Z
|
|
575
|
-
|
|
576
|
-
// A stated offset always wins, and assumeOffsetMinutes is ignored rather than blended:
|
|
577
|
-
toDate(parseDtm("20250102153045-0500")); // 2025-01-02T20:30:45.000Z
|
|
578
|
-
toDate(parseDtm("20250102153045-0500"), { assumeOffsetMinutes: 600 }); // the same instant
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
The host machine's timezone is never read and UTC is never assumed, so the answer does not depend on
|
|
582
|
-
where your code ran. Components below the stated precision fill to their lowest legal value for the
|
|
583
|
-
instant only, leaving the value's own precision untouched, and a four-digit year below 100 stays that
|
|
584
|
-
year (`00500101` is year 50, never 1950).
|
|
585
|
-
|
|
586
|
-
**An offset that is not a finite number is no offset at all.** `assumeOffsetMinutes` is signed
|
|
587
|
-
minutes east of UTC, and anything else names no zone, so `toDate` answers `undefined` rather than
|
|
588
|
-
coercing it into one. That matters from JavaScript, where the type is not checked for you:
|
|
589
|
-
|
|
590
|
-
```ts
|
|
591
|
-
import { parseDtm, toDate } from "@cosyte/hl7";
|
|
592
|
-
|
|
593
|
-
const dob = parseDtm("20240229"); // day precision, no offset stated
|
|
594
|
-
|
|
595
|
-
toDate(dob, null); // undefined: no options bag is not a zone, and this does not throw
|
|
596
|
-
toDate(dob, {}); // undefined: same answer as passing nothing
|
|
597
|
-
toDate(dob, { assumeOffsetMinutes: "0" }); // undefined: a string is not a number of minutes
|
|
598
|
-
toDate(dob, { assumeOffsetMinutes: Number.NaN }); // undefined: neither is NaN
|
|
599
|
-
|
|
600
|
-
toDate(dob, { assumeOffsetMinutes: 0 }); // 2024-02-29T00:00:00.000Z: a real choice, honoured
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
`"0"`, `true` and `[]` all multiply to `0` in JavaScript, so a converter that simply did the
|
|
604
|
-
arithmetic would hand back a UTC instant nobody asked for, which is the guess this surface exists to
|
|
605
|
-
refuse. An offset large enough to leave the range a `Date` represents is refused the same way, so no
|
|
606
|
-
answer is ever an `Invalid Date`. A value that states its own offset is unaffected: its offset wins
|
|
607
|
-
outright, so an unusable assumption beside one is ignored rather than fatal.
|
|
608
|
-
|
|
609
|
-
**`toISO` renders, `formatDtm` round-trips.** A stated zero offset renders as `Z`, including HL7's
|
|
610
|
-
`-0000` form, so the two answers differ by design and `formatDtm` remains the byte-exact route back to
|
|
611
|
-
the wire:
|
|
612
|
-
|
|
613
|
-
```ts
|
|
614
|
-
import { formatDtm, parseDtm, toISO } from "@cosyte/hl7";
|
|
615
|
-
|
|
616
|
-
const ts = parseDtm("20250102153045-0000");
|
|
617
|
-
toISO(ts); // "2025-01-02T15:30:45Z"
|
|
618
|
-
formatDtm(ts); // "20250102153045-0000": byte-exact, sign preserved
|
|
619
|
-
```
|
|
620
|
-
|
|
621
|
-
All three return `undefined` rather than throwing for a value the parser marked invalid, for
|
|
622
|
-
`undefined` and for `null`.
|
|
623
|
-
|
|
624
|
-
**An impossible date converts to nothing, never to the day after it.** A value stating a day its
|
|
625
|
-
month does not have is refused by all three, whole, rather than rolled over into the following
|
|
626
|
-
month. February really has 28 days, or 29 in a leap year under the full 4/100/400 rule:
|
|
627
|
-
|
|
628
|
-
```ts
|
|
629
|
-
import { parseDtm, toDate, toISO, toObject } from "@cosyte/hl7";
|
|
630
|
-
|
|
631
|
-
toObject(parseDtm("20240230")); // undefined: February has no 30th
|
|
632
|
-
toISO(parseDtm("20230229")); // undefined: 2023 is not a leap year
|
|
633
|
-
toDate(parseDtm("21000229"), { assumeOffsetMinutes: 0 }); // undefined: 2100 is not one either
|
|
634
|
-
|
|
635
|
-
toISO(parseDtm("20240229")); // "2024-02-29": 2024 is, so this one converts
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
`month` is bounded 1 to 12, `day` by the month it is in, `hour` 0 to 23, and `minute` and `second`
|
|
639
|
-
0 to 59. The parser itself is unchanged and stays deliberately liberal, so `parseDtm("20240230")`
|
|
640
|
-
is still `valid: true` and `formatDtm` still round-trips those bytes: the refusal is in the
|
|
641
|
-
conversion, which is where a wrong answer would otherwise look right. Rendering it would be worse
|
|
642
|
-
than throwing, because `new Date("2024-02-30")` is 1 March in every JavaScript runtime, so a
|
|
643
|
-
consumer reading the string gets a silent one-day shift instead of an error.
|
|
644
|
-
|
|
645
|
-
**The offset a value states is bounded on the same terms.** When a value claims an explicit offset,
|
|
646
|
-
that offset has to be a whole number of minutes within 23 hours 59 minutes of UTC, which is exactly
|
|
647
|
-
what `+HH:MM` can state and what an ISO-8601 reader accepts. Anything else names no zone, so the
|
|
648
|
-
value converts to `undefined` rather than being reported as an offset, rendered into a string no
|
|
649
|
-
reader can read back, or turned into an instant:
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
import { parseHL7, toDate, toISO } from "@cosyte/hl7";
|
|
653
|
-
|
|
654
|
-
// A timestamp on the wire, read by the lenient fallback, stating +99:99:
|
|
655
|
-
const ts = parseHL7("MSH|^~\\&|SEND|FAC|RECV|FAC|2024-02-29T12:00:00+99:99||ADT^A01|MSG1|P|2.5\r")
|
|
656
|
-
.meta.timestamp;
|
|
657
|
-
|
|
658
|
-
toISO(ts); // undefined: 6039 minutes east of UTC is no zone, and "+100:39" is unreadable
|
|
659
|
-
toDate(ts); // undefined: an offset that names no zone yields no instant
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
The same bound catches an `offsetMinutes` a JavaScript caller puts on a value by hand, where a
|
|
663
|
-
`"0"`, a `true` or an `[]` would otherwise multiply to `0` and answer a confident UTC instant, and
|
|
664
|
-
it catches `NaN`, which would otherwise render as `+NaN:NaN`. Every real zone on Earth is far inside
|
|
665
|
-
it (the widest in use is 14 hours east), a stated offset still wins outright over any
|
|
666
|
-
`assumeOffsetMinutes` beside it, and `parseDtm`, `formatDtm` and `dtmToDate` are unchanged.
|
|
667
|
-
|
|
668
|
-
**Using two `@cosyte` parsers in one file.** The three names are identical in `@cosyte/hl7`,
|
|
669
|
-
`@cosyte/x12`, `@cosyte/dicom`, `@cosyte/ncpdp`, `@cosyte/astm` and `@cosyte/ccda`, so importing two
|
|
670
|
-
of them into one file collides. Alias on import:
|
|
671
|
-
|
|
672
|
-
```ts
|
|
673
|
-
import { parseDtm, toISO as hl7ToISO } from "@cosyte/hl7";
|
|
674
|
-
import { toISO as x12ToISO } from "@cosyte/x12";
|
|
675
|
-
|
|
676
|
-
hl7ToISO(parseDtm("19880705")); // "1988-07-05"
|
|
677
|
-
// x12ToISO does the same for an X12 date value: same three names, same semantics.
|
|
678
|
-
```
|
|
679
|
-
|
|
680
|
-
...or namespace-import, which keeps the package of origin visible at every call site:
|
|
681
|
-
|
|
682
|
-
```ts
|
|
683
|
-
import * as hl7 from "@cosyte/hl7";
|
|
684
|
-
import * as x12 from "@cosyte/x12";
|
|
685
|
-
|
|
686
|
-
hl7.toISO(hl7.parseDtm("19880705")); // "1988-07-05"
|
|
687
|
-
// x12.toISO(...) is the same call, against an X12 date value.
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
#### Non-standard timestamp formats
|
|
691
|
-
|
|
692
|
-
HL7's canonical `YYYYMMDDHHmmss` parses with zero warnings. For everything else (vendor-quirky
|
|
693
|
-
`MM/DD/YYYY`, ISO `YYYY-MM-DD`, legacy `YYYYMMDD HHmm`), tell the parser what your sender writes
|
|
694
|
-
using the `dateFormats` option. Each format is tried in order, the first match wins, and it
|
|
695
|
-
populates the same fidelity `TS`.
|
|
696
|
-
|
|
697
|
-
```ts
|
|
698
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
699
|
-
|
|
700
|
-
const msg = parseHL7(raw, {
|
|
701
|
-
dateFormats: ["MM/DD/YYYY HH:mm:ss", "MM/DD/YYYY", "YYYY-MM-DD"],
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
console.log(msg.meta.timestamp?.matchedFormat); // e.g. "MM/DD/YYYY": which declared format won
|
|
705
|
-
console.log(msg.patient?.dateOfBirth?.matchedFormat); // the same list reaches PID-7
|
|
706
|
-
```
|
|
707
|
-
|
|
708
|
-
**The formats you declare reach every datetime the library returns**, not just the message header
|
|
709
|
-
timestamp: `patient.dateOfBirth`, `visit.admitDateTime` / `dischargeDateTime`, each
|
|
710
|
-
`observations()` entry's `observedDateTime` and its `TS`/`DT` typed value, `allergies()`
|
|
711
|
-
onset dates, `diagnoses()` date/times, `insurance()` effective and expiration dates,
|
|
712
|
-
`immunizations()` administered and expiration dates, `charges()` transaction dates,
|
|
713
|
-
`documents()` activity date/times, order and medication `timings` (a `TQ1` segment and the legacy
|
|
714
|
-
embedded `TQ` alike), and `appointments()` start and end times.
|
|
715
|
-
|
|
716
|
-
`matchedFormat` is how you tell which answer you got: it names the declared format that matched,
|
|
717
|
-
and is absent when the value was canonical HL7 and parsed strictly. There is no parse warning for
|
|
718
|
-
either case.
|
|
719
|
-
|
|
720
|
-
**Only the formats you declare are tried on those fields.** A date the parser was not told about
|
|
721
|
-
stays `valid: false` with its `raw` text intact, rather than being guessed at: guessing is how a
|
|
722
|
-
day-first `05/07/1988` becomes a confident May 7 on a date of birth, and a plausible wrong date is
|
|
723
|
-
worse than a missing one. `BUILTIN_DATE_FALLBACKS` (ISO-8601 and the US-order slash forms) is a
|
|
724
|
-
last resort for `msg.meta.timestamp` alone and never runs on a typed datetime field.
|
|
725
|
-
|
|
726
|
-
A format is written from a fixed token vocabulary (`SUPPORTED_DATE_TOKENS`) that covers month names (`05-JUL-1988`), a 12-hour clock with AM/PM (`7/5/1988 2:30 PM`), single-digit tolerance (`M`, `D`, `H`) and escaped literals (`YYYY-MM-DD[T]HH:mm:ss`). It carries no two-digit-year token, because resolving one needs a century window and a wrong window moves a date of birth by a hundred years without failing. `defineProfile()` refuses a format the vocabulary cannot honour, at definition time, rather than accepting it and never matching. The whole grammar, with every token, rule and exclusion, is the `Date token grammar` page in the documentation.
|
|
727
|
-
|
|
728
|
-
Built-in vendor profiles (`profiles.epic`, `profiles.genericLab`, etc.) already carry the date formats common to that vendor, and an option format is tried ahead of a profile's. Reach for a profile instead of hand-listing formats when one fits.
|
|
729
|
-
|
|
730
|
-
#### Day-first vs month-first: the parser refuses to guess
|
|
731
|
-
|
|
732
|
-
`05/07/1988` is 5 July to a day-first sender and May 7 to a month-first one. Both are real calendar
|
|
733
|
-
dates, and nothing in the message says which was meant. When no format has been declared, the
|
|
734
|
-
parser resolves **neither**: `msg.meta.timestamp` comes back `valid: false` carrying an `ambiguity`
|
|
735
|
-
report that names the raw value and both readings, so a wrong date never reaches your code silently.
|
|
736
|
-
|
|
737
|
-
```ts
|
|
738
|
-
import { parseHL7, AMBIGUOUS_DATE_ORDER } from "@cosyte/hl7";
|
|
739
|
-
|
|
740
|
-
const ts = parseHL7(raw).meta.timestamp; // MSH-7 was "05/07/1988"
|
|
741
|
-
|
|
742
|
-
if (ts?.ambiguity?.code === AMBIGUOUS_DATE_ORDER) {
|
|
743
|
-
console.log(ts.ambiguity.raw); // "05/07/1988"
|
|
744
|
-
console.log(ts.ambiguity.candidates[0]); // { format: "MM/DD/YYYY", month: 5, day: 7, isoDate: "1988-05-07" }
|
|
745
|
-
console.log(ts.ambiguity.candidates[1]); // { format: "DD/MM/YYYY", month: 7, day: 5, isoDate: "1988-07-05" }
|
|
746
|
-
}
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
Declaring the sender's order is the fix, and it is one line. A declared format is tried ahead of the
|
|
750
|
-
built-ins, so the value resolves and no ambiguity is reported:
|
|
751
|
-
|
|
752
|
-
```ts
|
|
753
|
-
const dayFirst = parseHL7(raw, { dateFormats: ["DD/MM/YYYY"] });
|
|
754
|
-
console.log(dayFirst.meta.timestamp?.month); // 7: 5 July 1988
|
|
755
|
-
|
|
756
|
-
const monthFirst = parseHL7(raw, { dateFormats: ["MM/DD/YYYY"] });
|
|
757
|
-
console.log(monthFirst.meta.timestamp?.month); // 5: May 7 1988
|
|
758
|
-
```
|
|
759
|
-
|
|
760
|
-
Only genuinely two-way values are refused. `07/25/1988` has one reading (there is no month 25) and
|
|
761
|
-
still resolves as July 25. `05/05/1988` has two readings that agree and still resolves. Strict HL7
|
|
762
|
-
timestamps, ISO-8601 values and `YYYY-MM-DD` values are untouched. Ambiguity is also a different fact
|
|
763
|
-
from malformed input: a value that is illegal under both readings reports no timestamp and no
|
|
764
|
-
ambiguity.
|
|
765
|
-
|
|
766
|
-
The report belongs to `msg.meta.timestamp`, because it is the built-in list that produces the second
|
|
767
|
-
reading and that list runs there and nowhere else. On a typed datetime field, an undeclared
|
|
768
|
-
`05/07/1988` is simply `valid: false` with its `raw` intact and no `ambiguity`: nothing tried to read
|
|
769
|
-
it, so there was no guess to refuse. Declaring the order resolves it on every field at once.
|
|
770
|
-
|
|
771
|
-
### Stripping MLLP framing
|
|
772
|
-
|
|
773
|
-
MLLP (Minimum Lower Layer Protocol) wraps HL7 messages in VT / FS / CR bytes for TCP transport. The parser strips them by default and emits a `MLLP_FRAMING_STRIPPED` warning so you know preprocessing happened.
|
|
774
|
-
|
|
775
|
-
```ts
|
|
776
|
-
import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
|
|
777
|
-
|
|
778
|
-
const msg = parseHL7(mllpFramed);
|
|
779
|
-
const framed = msg.warnings.some((w) => w.code === WARNING_CODES.MLLP_FRAMING_STRIPPED);
|
|
780
|
-
console.log(framed); // true if input had VT/FS/CR framing
|
|
781
|
-
|
|
782
|
-
// Disable preprocessing if your input is guaranteed framing-free:
|
|
783
|
-
parseHL7(raw, { stripMllpFraming: false });
|
|
784
|
-
```
|
|
785
|
-
|
|
786
|
-
Strip behaviour is idempotent: calling `parseHL7` on already-stripped input is a no-op. The parser does NOT add MLLP framing on `toString()`; if you need it for transport, prepend/append the bytes in your transport layer (a future `@cosyte/hl7-mllp` package will cover network IO end to end).
|
|
787
|
-
|
|
788
|
-
### Batch files
|
|
789
|
-
|
|
790
|
-
Real lab / ELR / IIS feeds ship many messages wrapped in the HL7 v2 batch envelope (`[FHS] { [BHS] { MSH… } [BTS] } [FTS]`). `splitBatch()` demarcates the individual messages by `MSH` boundary and hands each one back **already parsed**: a malformed message mid-stream is isolated (returned as a typed failure), never suppressing its siblings, and the declared BTS-1 / FTS-1 counts are reconciled without ever dropping the tail.
|
|
791
|
-
|
|
792
|
-
```ts
|
|
793
|
-
import { splitBatch } from "@cosyte/hl7";
|
|
794
|
-
|
|
795
|
-
const { messages, warnings } = splitBatch(rawBatchFile);
|
|
796
|
-
|
|
797
|
-
for (const entry of messages) {
|
|
798
|
-
if (entry.ok) {
|
|
799
|
-
handle(entry.message); // a fully-parsed Hl7Message
|
|
800
|
-
} else {
|
|
801
|
-
quarantine(entry.raw, entry.error.code); // isolated, not dropped
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Batch-level warnings carry counts/positions only, never PHI.
|
|
806
|
-
for (const w of warnings) {
|
|
807
|
-
// BATCH_COUNT_MISMATCH: declared BTS-1/FTS-1 ≠ actual (tail still returned)
|
|
808
|
-
// BATCH_MISSING_TRAILER: a BHS/FHS header with no matching BTS/FTS
|
|
809
|
-
}
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
A **bare single message** (no envelope) passes straight through as a one-message result. `splitBatch` is a splitter, not an enforcer: it warns on a missing trailer but leaves accept/reject to you (a profile such as IIS may mandate the full frame), does not generate batch ACKs, and does not de-batch across transport framing (that is [`@cosyte/mllp`](https://github.com/cosyte/mllp)). The second argument is forwarded to `parseHL7` per message (profile, `strict`, `charset`). See the [batch spec notes](./docs-content/spec-notes-batch.md) for the full traceability + known limitations.
|
|
813
|
-
|
|
814
|
-
### Streaming large files (`parseStream`)
|
|
815
|
-
|
|
816
|
-
`splitBatch` needs the whole file in memory. For a feed or file too large to buffer (or a live source) `parseStream()` parses **incrementally**: it consumes a chunked source (a Node `Readable`, an async-iterable, or an iterable of `string`/`Buffer` chunks) and **yields one message per `MSH` boundary as it completes**, holding only **O(one message)** of state. A message split across chunk boundaries (mid-segment, mid-field, even mid-`MSH|^~\&`) is reassembled: feeding the same bytes in 1-byte chunks or one big chunk yields identical messages.
|
|
817
|
-
|
|
818
|
-
```ts
|
|
819
|
-
import { createReadStream } from "node:fs";
|
|
820
|
-
import { parseStream, WARNING_CODES } from "@cosyte/hl7";
|
|
821
|
-
|
|
822
|
-
for await (const entry of parseStream(createReadStream("elr-feed.hl7"))) {
|
|
823
|
-
if (entry.ok) {
|
|
824
|
-
handle(entry.message); // released before the next is read: bounded memory
|
|
825
|
-
} else {
|
|
826
|
-
quarantine(entry.raw, entry.error.code); // isolated; the tail still streams
|
|
827
|
-
}
|
|
828
|
-
// Stream-level (not per-message) diagnostics, never PHI:
|
|
829
|
-
for (const w of entry.streamWarnings) {
|
|
830
|
-
// UNTERMINATED_STREAM_MESSAGE: the final message had no terminator (truncated feed?)
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
Each message is parsed by `parseHL7` (no second grammar; the same second argument is forwarded per message), so a streamed message is byte-for-byte what `splitBatch` would produce for the same bytes. A malformed message mid-stream is an isolated typed failure entry (it **never suppresses** later messages) and an unterminated final message is still yielded in full, flagged (never thrown). Batch-envelope segments (`FHS`/`BHS`/`BTS`/`FTS`) act as boundaries and are never yielded, so the yielded count equals the `MSH` count; envelope **count reconciliation** stays `splitBatch`'s job. Transport framing (MLLP) is out of scope: this consumes an already-de-framed stream; the wire is [`@cosyte/mllp`](https://github.com/cosyte/mllp)'s. See the [streaming spec notes](./docs-content/spec-notes-stream.md).
|
|
836
|
-
|
|
837
|
-
### Detect message type
|
|
838
|
-
|
|
839
|
-
`msg.meta` exposes MSH-9 pre-decomposed into its three components: use them instead of parsing the raw string.
|
|
840
|
-
|
|
841
|
-
```ts
|
|
842
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
843
|
-
|
|
844
|
-
const msg = parseHL7(raw);
|
|
845
|
-
|
|
846
|
-
console.log(msg.meta.type); // "ADT^A01"
|
|
847
|
-
console.log(msg.meta.messageCode); // "ADT"
|
|
848
|
-
console.log(msg.meta.triggerEvent); // "A01"
|
|
849
|
-
console.log(msg.meta.messageStructure); // "ADT_A01" (if present in MSH-9.3)
|
|
850
|
-
|
|
851
|
-
if (msg.meta.messageCode === "ORU") {
|
|
852
|
-
// branch for observation results
|
|
853
|
-
}
|
|
854
|
-
```
|
|
855
|
-
|
|
856
|
-
Matching on `messageCode` + `triggerEvent` is more robust than string-equals on `type`, because some senders populate MSH-9.3 (`type` includes it) and some don't.
|
|
857
|
-
|
|
858
|
-
### Narrow a message to its type
|
|
859
|
-
|
|
860
|
-
`msg.is("ADT^A01")` does the same comparison and tells the **compiler** the answer. Inside the guard the message code and trigger event are literal types rather than `string | undefined`, and `part` / `parts` accept only the segment names that message type's published structure marks required. No cast, and no type to import per message type.
|
|
861
|
-
|
|
862
|
-
```ts
|
|
863
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
864
|
-
|
|
865
|
-
const msg = parseHL7(raw);
|
|
866
|
-
|
|
867
|
-
if (msg.is("ADT^A01")) {
|
|
868
|
-
const code: "ADT" = msg.meta.messageCode; // literal, and no longer optional
|
|
869
|
-
const event: "A01" = msg.meta.triggerEvent;
|
|
870
|
-
console.log(code, event);
|
|
871
|
-
|
|
872
|
-
const pid = msg.part("PID"); // Segment | undefined: first PID, or none
|
|
873
|
-
const evn = msg.parts("EVN"); // readonly Segment[]: every EVN, possibly empty
|
|
874
|
-
console.log(pid?.field(3).value, evn.length);
|
|
875
|
-
|
|
876
|
-
// msg.part("OBX"); // does not compile: ADT^A01 does not require OBX
|
|
877
|
-
console.log(msg.segments("OBX").length); // the base accessor still takes any name
|
|
878
|
-
}
|
|
879
|
-
```
|
|
880
|
-
|
|
881
|
-
A key is `"<MSH-9.1>^<MSH-9.2>"` (`"ADT^A01"`), or `"<MSH-9.1>"` alone for a message type the published structure registry matches on message code alone (`"ACK"`, whose MSH-9.2 carries the acknowledged message's trigger event). `SUPPORTED_OVERLAY_MESSAGES` enumerates every key with the segments its structure requires, derived from that registry rather than written down.
|
|
882
|
-
|
|
883
|
-
The check is on the (MSH-9.1, MSH-9.2) pair the parser extracted, so a message whose MSH-9 carries the three-component `ADT^A01^ADT_A01` still answers `true` to `is("ADT^A01")`. **Any string that is not a key returns `false` and never throws**: an unrecognized type, the three-component form as a string, an empty string, or a value computed at run time (which cannot narrow anything, so it returns a plain `boolean`). For a raw comparison, `msg.meta.type` is the string MSH-9 carried.
|
|
884
|
-
|
|
885
|
-
Narrowing does not promise presence. The parser is lenient by design, and an `ADT^A01` that arrives without its `PID` still answers `true` here, still warns, and still reports the absence on `msg.structure`: `part` returns `undefined` and `parts` an empty list, exactly as `segments` does.
|
|
886
|
-
|
|
887
|
-
### Spot a truncated or misrouted message
|
|
888
|
-
|
|
889
|
-
For the message types it covers, `msg.structure` reports whether the segments HL7's own published message-structure definitions give a **minimum of one** for that trigger event are actually present. It's a **misroute / truncation safety net**, not a conformance validator: an `ORU^R01` that arrives with no `OBR` is almost always truncated or sent to the wrong feed.
|
|
890
|
-
|
|
891
|
-
```ts
|
|
892
|
-
import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
|
|
893
|
-
|
|
894
|
-
const msg = parseHL7(raw); // ORU^R01 with only MSH + PID
|
|
895
|
-
|
|
896
|
-
console.log(msg.structure.recognized); // true
|
|
897
|
-
console.log(msg.structure.missingSegments); // ["OBR"]
|
|
898
|
-
console.log(msg.structure.structureIds); // ["ORU_R01-A", "ORU_R01-B", ...]
|
|
899
|
-
|
|
900
|
-
// The same finding also surfaces as an additive Tier-2 warning, so a
|
|
901
|
-
// channel can route on it without inspecting `structure`:
|
|
902
|
-
if (msg.warnings.some((w) => w.code === WARNING_CODES.MISSING_EXPECTED_GROUP)) {
|
|
903
|
-
// quarantine / alert: the message is missing an expected segment
|
|
904
|
-
}
|
|
905
|
-
```
|
|
906
|
-
|
|
907
|
-
Expectations are **derived, not hand-picked**: the package vendors a byte-for-byte snapshot of the published structures and derives the registry from it offline, with no network call at build time or run time. `STRUCTURE_REGISTRY_PROVENANCE` carries the publication, its commit, the sha256 of every vendored file and the structure behind every recognized pair, so a warning can be audited without leaving the package.
|
|
908
|
-
|
|
909
|
-
It stays conservative where the publication is: a segment inside an optional group is not expected (so a conformant `OBX`-free `ORU^R01` never warns), a segment is expected only when every published variant of the structure requires it, and a type it doesn't recognize yields `recognized: false` and emits nothing. It never throws and never rewrites the message. `strict` mode may promote the warning to an error per the usual model. Recognized message codes: ADT, ORU, ORM, OML, OMG, OMP, OMI, SIU, MDM, DFT, VXU and ACK, across 94 trigger-event pairs. See [`docs-content/spec-notes-structure.md`](docs-content/spec-notes-structure.md) for the full table, the derivation rules, and what changed for consumers.
|
|
910
|
-
|
|
911
|
-
### Check a message against HL7's published shape (`validateMessageStructure`)
|
|
912
|
-
|
|
913
|
-
`msg.structure` asks only whether a required segment is **present**. When the question is "does this feed follow the shape the standard publishes for this trigger event?", ask for `validateMessageStructure(msg)`: an **opt-in, read-only** check over the same vendored publication, at segment granularity. Nothing calls it for you and it changes nothing: no new warning code, no change to a parse, and the message is byte-identical afterwards.
|
|
914
|
-
|
|
915
|
-
```ts
|
|
916
|
-
import { parseHL7, validateMessageStructure } from "@cosyte/hl7";
|
|
917
|
-
|
|
918
|
-
const result = validateMessageStructure(parseHL7(raw)); // ADT^A01 with PV1 before PID
|
|
919
|
-
|
|
920
|
-
console.log(result.validated); // true
|
|
921
|
-
console.log(result.structureId); // "ADT_A01-A": the variant the findings are against
|
|
922
|
-
console.log(result.structureIds); // the whole variant family that was considered
|
|
923
|
-
for (const f of result.findings) console.log(f.severity, f.code, f.locus.segment, f.message);
|
|
924
|
-
// e.g. 'error STRUCTURE_SEGMENT_OUT_OF_ORDER PID Segment "PID" (occurrence 0) appears where published structure ADT_A01-A does not allow it.'
|
|
925
|
-
```
|
|
926
|
-
|
|
927
|
-
It reports three things: `STRUCTURE_SEGMENT_OUT_OF_ORDER` (the sequence stops being a beginning the published order allows), `STRUCTURE_SEGMENT_CARDINALITY` (fewer occurrences than the published minimum along the path from the structure root, or more than the published maximum), and `STRUCTURE_SEGMENT_UNEXPECTED` (a segment the publication does not name, a `Z` segment included, at `warning` severity). Every finding carries a **PHI-free locus**: a segment name, a 0-indexed occurrence and a published structure id, never a field value.
|
|
928
|
-
|
|
929
|
-
The order check reads the publication's bounds as written, at every locus: a group it lets repeat may repeat, which is what lets a conformant `ORU^R01` carry `OBR OBX OBR OBX`, a group bounded at one occurrence may not be re-entered, so a `VXU^V04` whose `PV2` arrives before its `PV1` is reported, and no occurrence of a group may begin without the child the publication requires first, so an `ADT^A01` whose `IN2` arrives before its `IN1` is reported as well. The three questions are still kept apart so one defect is reported once: the one bound dropped for a segment name is the one the cardinality check has already reported for it, so a missing or over-repeated segment does not also read as everything after it being out of place. Where two segments arrive in an order the publication does not allow, the finding names **the one that arrived late**: the segment the publication puts first and the message delivered second, which is not always where the reading stopped, because the publication is often free to skip past a segment the message delayed. The pair is found by exchanging adjacent segments and asking whether the publication derives the result, and every adjacent pair is asked rather than a shortlist near the stop: in a message that repeats a group the pair can sit well in front of it, as in an `OML^O21` carrying three orders whose first order's note is delivered after the second order's `ORC`. Where more than one pair would do, the earliest is named.
|
|
930
|
-
|
|
931
|
-
**Read `validated` before `findings`.** A message type the registry does not model, the one retained transcription (`ORM^O01`), a structure with no ordered expectation and a message with no readable type each come back `validated: false` with a `reason` and no findings: "the publication cannot answer" is a different answer from "nothing was wrong". Where the publication splits a structure into variants, conforming to **one** variant conforms to the family, so findings appear only when every variant is violated and they are exactly one named variant's.
|
|
932
|
-
|
|
933
|
-
> **Zero findings is not an attestation.** It means this message did not break the published structure in the three ways above. Field content, datatypes, value sets and HL7 tables are unchecked, coverage is twelve message codes, and the publication is vendored at a fixed commit. See [`docs-content/spec-notes-structure.md`](docs-content/spec-notes-structure.md).
|
|
934
|
-
|
|
935
|
-
### Validate against your own conformance profile (`validateAgainstProfile`)
|
|
936
|
-
|
|
937
|
-
`msg.structure` checks the handful of groups the base spec marks Required. When you have a **specific interface spec** ("our ADT feed requires PID-3, sex must be M/F/U, no Z-segments"), bring it as a declarative **conformance profile** and `validateAgainstProfile(msg, profile)` returns typed findings. **You author the profile and every value set; hl7 ships none**: no bundled vendor/IHE profile, no code set, no network call.
|
|
938
|
-
|
|
939
|
-
```ts
|
|
940
|
-
import { parseHL7, validateAgainstProfile, type ConformanceProfile } from "@cosyte/hl7";
|
|
941
|
-
|
|
942
|
-
// A profile YOU author: an example, NOT an attestation of conformance.
|
|
943
|
-
const profile: ConformanceProfile = {
|
|
944
|
-
name: "our-adt-intake",
|
|
945
|
-
segments: [
|
|
946
|
-
{
|
|
947
|
-
segment: "PID",
|
|
948
|
-
usage: "R",
|
|
949
|
-
fields: [
|
|
950
|
-
{ field: 3, name: "Patient Identifiers", usage: "R", cardinality: { min: 1, max: 1 } },
|
|
951
|
-
{ field: 8, name: "Administrative Sex", usage: "RE", valueSet: ["M", "F", "U"] },
|
|
952
|
-
],
|
|
953
|
-
},
|
|
954
|
-
{ segment: "ZZZ", usage: "X" }, // no local Z-segments permitted
|
|
955
|
-
],
|
|
956
|
-
};
|
|
957
|
-
|
|
958
|
-
const { findings } = validateAgainstProfile(parseHL7(raw), profile);
|
|
959
|
-
for (const f of findings) console.log(f.severity, f.code, f.message);
|
|
960
|
-
// e.g. "error PROFILE_VALUE_NOT_IN_SET PID-8 component 1 value is not in the profile value set (3 permitted codes)."
|
|
961
|
-
```
|
|
962
|
-
|
|
963
|
-
The engine holds four invariants: it **never throws** (a malformed profile yields `PROFILE_MALFORMED` findings, not an exception: use `defineConformanceProfile` for a fail-fast authoring gate that _does_ throw); a **valid message yields zero findings**; **no finding carries PHI**: each names the structural locus (segment / field / component / repetition) and the rule, never the offending value; and validation is **read-only**. Distinct from the parse-profile system (`defineProfile`/`profiles`), which shapes _how a message is parsed_.
|
|
964
|
-
|
|
965
|
-
A rule's usage is one of `R`/`RE`/`C`/`CE`/`O`/`X`/`B`, or a declared conditional written `C(t/f)`. A conditionally-used rule can carry a **condition predicate** that the engine evaluates against the message to decide which outcome applies: a location in the same message, a verb from the closed set (`is`, `is not`, `contains`, `does not contain`, `matches`, `does not match`) or a presence statement (`is valued`, `is not valued`), a value list _you_ supply, and `AND`/`OR`/`XOR` connectors.
|
|
966
|
-
|
|
967
|
-
```ts
|
|
968
|
-
// "PID-8 is Required when a date of birth was sent, Not-permitted when it was not."
|
|
969
|
-
{
|
|
970
|
-
field: 8,
|
|
971
|
-
usage: "C(R/X)",
|
|
972
|
-
condition: { location: { segment: "PID", field: 7 }, presence: "is valued" },
|
|
973
|
-
}
|
|
974
|
-
```
|
|
975
|
-
|
|
976
|
-
Still no network call and still no code set: a predicate reads the message in front of it and compares against values you wrote down. A predicate the message cannot decide (a comparison against an element that is not there) yields a `PROFILE_CONDITION_UNEVALUATABLE` finding and the element's presence is **not** assessed, rather than being guessed either way. A rule that declares no predicate can still take its outcome from a caller-supplied resolution, and declaring both at one locus is refused.
|
|
977
|
-
|
|
978
|
-
> **"No findings" is not an attestation.** An empty result means _nothing this profile checked was violated_: never "this message is conformant." The profile only covers what you declared, and hl7 makes no conformance certification. See [`docs-content/spec-notes-conformance.md`](docs-content/spec-notes-conformance.md).
|
|
979
|
-
|
|
980
|
-
### Pretty-print for logs
|
|
981
|
-
|
|
982
|
-
`msg.prettyPrint()` returns a multi-line, labeled view of the positional tree: useful for dev-time debugging and log snapshots.
|
|
983
|
-
|
|
984
|
-
```ts
|
|
985
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
986
|
-
|
|
987
|
-
const msg = parseHL7(raw);
|
|
988
|
-
console.log(msg.prettyPrint());
|
|
989
|
-
// MSH-1 (field separator): |
|
|
990
|
-
// MSH-2 (encoding chars): ^~\&
|
|
991
|
-
// MSH-3 (sending app): CLINIC
|
|
992
|
-
// ...
|
|
993
|
-
// PID-5 (name):
|
|
994
|
-
// .1 = Smith
|
|
995
|
-
// .2 = Jane
|
|
996
|
-
// ...
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
Pretty-print is **not** a replacement for `toString()`. It's for humans. Use `toString()` for wire output and `toJSON()` when you need a serialisable structure.
|
|
111
|
+
That is the whole pitch: no config, no schema upload, no spec lookup. The parser accepts vendor-quirky input by default, strips MLLP framing if it's there, normalizes casing, and tolerates the dozen-or-so deviations real HL7 traffic routinely carries. You reach for strict mode, dot-paths, or profiles when you want them, not before.
|
|
1000
112
|
|
|
1001
113
|
---
|
|
1002
114
|
|
|
1003
|
-
##
|
|
1004
|
-
|
|
1005
|
-
Profiles are the growth loop. Built-ins cover the common vendor patterns; real integrations (hospital-specific Epic instances, reference labs, HIEs) publish their own profile packages that extend the built-ins. The `defineProfile()` API treats built-ins and user-authored profiles as equal citizens. There's no second-class path.
|
|
1006
|
-
|
|
1007
|
-
### Authoring a profile
|
|
1008
|
-
|
|
1009
|
-
`defineProfile({ name, customSegments, segmentOverrides, dateFormats, onWarning, description })` returns a frozen `Profile` object. `customSegments` is a record mapping Z-segment name -> `{ fields: { aliasName: 1-indexedPosition } }`; `segmentOverrides` is the same shape for STANDARD segment names (see [Naming fields on standard segments](#naming-fields-on-standard-segments)).
|
|
1010
|
-
|
|
1011
|
-
```ts
|
|
1012
|
-
import { defineProfile, type CustomSegmentDefinition } from "@cosyte/hl7";
|
|
1013
|
-
|
|
1014
|
-
const zdp: CustomSegmentDefinition = {
|
|
1015
|
-
fields: { departmentCode: 3, departmentName: 4 },
|
|
1016
|
-
};
|
|
1017
|
-
|
|
1018
|
-
const myhospital = defineProfile({
|
|
1019
|
-
name: "myhospital",
|
|
1020
|
-
description: "Custom Z-segments for MyHospital ADT feed",
|
|
1021
|
-
dateFormats: ["MM/DD/YYYY HH:mm:ss"],
|
|
1022
|
-
customSegments: { ZDP: zdp },
|
|
1023
|
-
});
|
|
1024
|
-
```
|
|
1025
|
-
|
|
1026
|
-
Invalid input (missing name, malformed Z-segment name, unsupported date tokens, unknown option keys) throws `ProfileDefinitionError` with an actionable message. See [Error Handling](#error-handling).
|
|
1027
|
-
|
|
1028
|
-
### Naming fields on standard segments
|
|
1029
|
-
|
|
1030
|
-
`customSegments` names fields on your Z-segments. `segmentOverrides` does the same job for the STANDARD segments, for the case every real integration eventually hits: a site that stuffs its own value into a standard field. A second MRN in PID-19, a local severity code in AL1-4, a site dose qualifier in RXA-6. Give it a name once in the profile and read it by that name everywhere, instead of scattering a hand-typed position through your code.
|
|
1031
|
-
|
|
1032
|
-
```ts
|
|
1033
|
-
import { defineProfile, parseHL7 } from "@cosyte/hl7";
|
|
1034
|
-
|
|
1035
|
-
const site = defineProfile({
|
|
1036
|
-
name: "myhospital-adt",
|
|
1037
|
-
segmentOverrides: {
|
|
1038
|
-
PID: { fields: { siteMrn: 19 } },
|
|
1039
|
-
AL1: { fields: { localSeverityCode: 4 } },
|
|
1040
|
-
RXA: { fields: { doseQualifier: 6 } },
|
|
1041
|
-
},
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
const msg = parseHL7(raw, site);
|
|
1045
|
-
msg.part("PID")?.get("siteMrn")?.value; // the value at PID-19
|
|
1046
|
-
msg.part("AL1")?.get("localSeverityCode")?.value; // the value at AL1-4
|
|
1047
|
-
```
|
|
1048
|
-
|
|
1049
|
-
**Positions are 1-indexed HL7 positions**, identical in meaning to the argument of `seg.field(n)`, including the MSH numbering convention: a binding at `1` is MSH-1 (the field separator) and a binding at `3` is MSH-3 (the sending application). `get(name)` and `field(position)` always agree.
|
|
1050
|
-
|
|
1051
|
-
**A name that is not declared, and a declared position the message did not carry, both read `undefined`**, never an empty string. A typo surfaces instead of quietly reading blank. The HL7 explicit null (`""`) is a value the sender chose to send, so it still comes back as a `Field`.
|
|
1052
|
-
|
|
1053
|
-
**Inheritance follows the same rule as `customSegments`.** Layer profiles with `extends` and, per segment type, the child's binding wins at any POSITION both declare while the parent's bindings at other positions survive:
|
|
1054
|
-
|
|
1055
|
-
```ts
|
|
1056
|
-
const parent = defineProfile({
|
|
1057
|
-
name: "parent",
|
|
1058
|
-
segmentOverrides: { PID: { fields: { parentAlias: 19, countyCode: 12 } } },
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
const child = defineProfile({
|
|
1062
|
-
name: "child",
|
|
1063
|
-
extends: parent,
|
|
1064
|
-
segmentOverrides: { PID: { fields: { childAlias: 19 } } },
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
// child.segmentOverrides.PID.fields === { childAlias: 19, countyCode: 12 }
|
|
1068
|
-
```
|
|
1069
|
-
|
|
1070
|
-
**What `defineProfile()` refuses, at definition time**, throwing `ProfileDefinitionError` and returning no profile:
|
|
1071
|
-
|
|
1072
|
-
| Declaration | Why it is refused |
|
|
1073
|
-
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1074
|
-
| a Z-segment key (`ZPI`) | that is a custom segment: the error points you at the `customSegments` option |
|
|
1075
|
-
| a name that is not a standard HL7 v2 segment (`QQQ`, `pid`, `PIDX`) | keys are matched against `KNOWN_SEGMENTS` in their canonical uppercase spelling, so a key that would never resolve is rejected rather than silently defining a dead name |
|
|
1076
|
-
| a position that is not a positive integer (`0`, `-1`, `2.5`, `"3"`) | the error names the profile, the segment and the field |
|
|
1077
|
-
| a misspelled option key (`segmentOverride`) | reported with `segmentOverrides` as the suggestion |
|
|
1078
|
-
|
|
1079
|
-
An empty map (`segmentOverrides: {}`), or a declared segment with an empty field map, is accepted and behaves exactly like declaring nothing.
|
|
1080
|
-
|
|
1081
|
-
#### Limitations
|
|
1082
|
-
|
|
1083
|
-
**Overrides add named reads. They change nothing that already reads.** This is the whole design, and it is deliberate: HL7 v2 traffic is PHI, and an alias that re-pointed an existing accessor would hand a downstream consumer a different clinical value with no warning and nothing to notice. Specifically, a `segmentOverrides` declaration does NOT:
|
|
1084
|
-
|
|
1085
|
-
- **re-point the typed clinical accessors.** `msg.patient`, `msg.visit`, `msg.allergies()`, `msg.medications()`, `msg.observations()` and every other typed helper keep reading the positions the HL7 standard assigns. Declaring `siteMrn: 19` does not make `msg.patient?.mrn` read PID-19; it still reads PID-3.
|
|
1086
|
-
- **add a dot-path.** Dot-paths stay positional: `msg.get("PID.19")` works, `msg.get("PID.siteMrn")` is a syntax error. The named accessor is the one route to a declared name.
|
|
1087
|
-
- **change serialization.** `toString()` is byte-for-byte what it was, `toJSON()` is unchanged, and the message still round-trips verbatim.
|
|
1088
|
-
- **change the warning list.** An undeclared Z-segment still emits `UNKNOWN_SEGMENT`, and naming a standard segment in the override map does not suppress or add any warning.
|
|
1089
|
-
- **touch the write path.** `setField`, `setComposite`, `buildMessage` and the typed builders take positions, not names.
|
|
1090
|
-
|
|
1091
|
-
Parse one message with the overrides and again without them and every one of those observables is equal. Only the new names differ.
|
|
1092
|
-
|
|
1093
|
-
### Extending profiles
|
|
1094
|
-
|
|
1095
|
-
Use `extends` to layer profiles. Single parent or array of parents, both supported.
|
|
1096
|
-
|
|
1097
|
-
```ts
|
|
1098
|
-
import { defineProfile, profiles } from "@cosyte/hl7";
|
|
1099
|
-
|
|
1100
|
-
const myEpic = defineProfile({
|
|
1101
|
-
name: "my-epic",
|
|
1102
|
-
extends: profiles.epic,
|
|
1103
|
-
customSegments: {
|
|
1104
|
-
ZPR: { fields: { providerId: 1 } },
|
|
1105
|
-
},
|
|
1106
|
-
});
|
|
1107
|
-
|
|
1108
|
-
const combined = defineProfile({
|
|
1109
|
-
name: "epic-plus-lab",
|
|
1110
|
-
extends: [profiles.epic, profiles.genericLab],
|
|
1111
|
-
});
|
|
1112
|
-
```
|
|
1113
|
-
|
|
1114
|
-
Lineage is recorded on the result (`profile.lineage`) in parent-first order, ending with the profile's own name.
|
|
1115
|
-
|
|
1116
|
-
### Merge semantics
|
|
1117
|
-
|
|
1118
|
-
When `extends` resolves parents, fields are merged per-key with the following rules:
|
|
1119
|
-
|
|
1120
|
-
- **Scalars** (`description`): later layers overwrite earlier ones.
|
|
1121
|
-
- **Arrays** (`dateFormats`, `lineage`): concatenate + dedupe, preserving first-seen order.
|
|
1122
|
-
- **`customSegments` map**: deep-merge per key; same-segment-name in two parents reconciles positional fields.
|
|
1123
|
-
- **`segmentOverrides` map**: the same reducer, on the standard-segment declarations. See [Naming fields on standard segments](#naming-fields-on-standard-segments).
|
|
1124
|
-
- **`onWarning` handlers**: compose into a chain, invoked in lineage order (parents before children). Errors thrown by one handler do not stop subsequent handlers.
|
|
1125
|
-
- **`name`**: never inherited, always the profile's own.
|
|
115
|
+
## Documentation
|
|
1126
116
|
|
|
1127
|
-
The
|
|
117
|
+
The full reference lives at [docs.cosyte.com/hl7](https://docs.cosyte.com/hl7), built from this repository's [`docs-content/`](https://github.com/cosyte/hl7/tree/main/docs-content). Start with these pages:
|
|
1128
118
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
import { defineProfile, profiles } from "@cosyte/hl7";
|
|
1135
|
-
|
|
1136
|
-
const p = defineProfile({
|
|
1137
|
-
name: "my-epic",
|
|
1138
|
-
extends: profiles.epic,
|
|
1139
|
-
customSegments: { ZPR: { fields: { providerId: 1 } } },
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
console.log(p.name); // "my-epic"
|
|
1143
|
-
console.log(p.lineage); // ["epic", "my-epic"]
|
|
1144
|
-
console.log(Object.keys(p.customSegments ?? {})); // ["ZDP", "ZRS", "ZPR"]
|
|
1145
|
-
console.log(p.describe?.()); // multi-line summary
|
|
1146
|
-
```
|
|
1147
|
-
|
|
1148
|
-
### Publishing a profile
|
|
1149
|
-
|
|
1150
|
-
The [profile starter kit](./examples/profile-starter-kit/) is a complete publishable npm package template. Copy the subtree, replace placeholders, swap in your Z-segments and fixtures, then `pnpm publish --access public`. See [`examples/profile-starter-kit/CUSTOMIZING.md`](./examples/profile-starter-kit/CUSTOMIZING.md) for the 5-step walkthrough: rename, swap base profile, define Z-segments, write fixtures, publish.
|
|
1151
|
-
|
|
1152
|
-
### Built-in profiles
|
|
1153
|
-
|
|
1154
|
-
Eight profiles ship in the box, reachable via the `profiles` namespace:
|
|
1155
|
-
|
|
1156
|
-
- `profiles.epic`: Epic Bridges Interconnect. Adds `MM/DD/YYYY HH:mm:ss` and `MM/DD/YYYY` date formats; declares `ZDP` (department context) and `ZRS` (result status) Z-segments.
|
|
1157
|
-
- `profiles.cerner`: Cerner Millennium outbound. Handles Cerner-idiomatic date formats and common Z-segments from Millennium ADT feeds.
|
|
1158
|
-
- `profiles.meditech`: MEDITECH MAGIC/6.x/Expanse. Adds the `YYYYMMDDHHMM` minute-precision timestamp format and declares the DFT charge Z-segments `ZF1` (provider-encounter copay data) and `ZF2` (encounter-procedure data). Grounded in the public [MEDITECH Ancillary Charges (LAB/PHA/ITS/IDM) Outbound](https://ehr.meditech.com/sites/default/files/documents/20240613/ancillary-charges-outbound-21.pdf) (v2.1) spec, with the date format also confirmed by the [MEDITECH Admissions and Registration Outbound](https://ehr.meditech.com/sites/default/files/documents/20240613/admissions-registration-outbound-24.pdf) (v2.4) spec.
|
|
1159
|
-
- `profiles.athena`: athenahealth. Ambulatory-oriented, ISO-leaning date formats.
|
|
1160
|
-
- `profiles.genericLab`: Generic reference-lab (LabCorp / Quest-style). Adds `YYYYMMDD HHmm` (ASTM-era) and `YYYY-MM-DD` (ISO date-only); declares `ZLB` (lab overrides) and `ZNT` (lab note) Z-segments.
|
|
1161
|
-
- `profiles.visage`: Visage 7 imaging/PACS RIS feeds. Declares the `ZDS` Z-segment that carries the DICOM **Study Instance UID** (field 1) so an HL7 order correlates to its DICOM study, the IHE Radiology RIS/PACS bridge segment. Grounded in the public [Visage 7 HL7 Interface Specification](https://www.visageimaging.com/downloads/Visage7/Visage7_HL7InterfaceSpecification.pdf) (V23.00, Jun 2026). Dates are HL7-native, so it adds no date formats.
|
|
1162
|
-
- `profiles.philips`: Philips Vue PACS ("IS Link") imaging feeds. Declares six Vue PACS Z-segments, one per filler role: `ZDS` (DICOM Study Instance UID), `ZLK` (linked studies/orders), `ZAO` (order additional details: modality, body part, transfer/acquisition status, technician + radiologist), `ZEB` (encrypted patient info), `ZAP` (patient additional details), and `ZAV` (visit additional details). Grounded in the public [Vue PACS 12.2.8 HL7 Interface Specifications](https://www.documents.philips.com/assets/Conformance%20Statements/20240409/8941f89d89aa4983aab7b14d00db578c.pdf) (Philips, doc HA1669 Rev A, §§5.11-5.16). Dates are HL7-native, so it adds no date formats.
|
|
1163
|
-
- `profiles.va`: U.S. Department of Veterans Affairs VistA Radiology/Nuclear Medicine feeds (HL7 v2.4). Declares the `ZDS` Z-segment that carries the DICOM **Study Instance UID** (field 1): the same IHE Radiology RIS↔PACS bridge as `visage`/`philips`, grounded here in a distinct **federal** spec and documented on **both ORM and ORU** (result) messages. Grounded in the public [Radiology/Nuclear Medicine 5.0 HL7 Interface Specification](https://www.va.gov/vdl/documents/clinical/radiology_nuclear_med/ra5_0hl7is.pdf) (Version 3.6, Patch RA\*5.0\*203, June 2024). Dates are HL7-native, so it adds no date formats.
|
|
1164
|
-
|
|
1165
|
-
Use a built-in directly (`parseHL7(raw, profiles.epic)`) or as a base for your own (`defineProfile({ extends: profiles.epic, ... })`).
|
|
119
|
+
- [Access patterns](https://github.com/cosyte/hl7/blob/main/docs-content/access-patterns.md): a 90-second HL7 v2 primer, then named helpers, dot-paths, and structural traversal.
|
|
120
|
+
- [Cookbook](https://github.com/cosyte/hl7/blob/main/docs-content/cookbook.md): demographics, lab results, edits, scheduling and charges, timing, identity events, dates, MLLP framing, batches, streams, type narrowing, and validation.
|
|
121
|
+
- [Profiles](https://github.com/cosyte/hl7/blob/main/docs-content/profiles.md): authoring, extending, composing, and publishing vendor profiles, and the built-in set.
|
|
122
|
+
- [Tolerance and error handling](https://github.com/cosyte/hl7/blob/main/docs-content/tolerance-and-errors.md): the four tolerance tiers, `Hl7ParseError`, `Hl7ParseWarning`, and `ProfileDefinitionError`.
|
|
123
|
+
- [Warning and fatal codes](https://github.com/cosyte/hl7/blob/main/docs-content/warning-and-fatal-codes.md): every stable code and what it means.
|
|
1166
124
|
|
|
1167
125
|
---
|
|
1168
126
|
|
|
1169
|
-
##
|
|
1170
|
-
|
|
1171
|
-
Production HL7 traffic routinely violates the published spec: trailing whitespace, MLLP framing, mixed-case segment names, unknown escape sequences, non-canonical timestamps. A parser that rejects those messages is useless on real integrations. Postel's Law applies: the parser is liberal, the emitter is conservative.
|
|
1172
|
-
|
|
1173
|
-
Every deviation the parser encounters is classified into one of four tiers:
|
|
1174
|
-
|
|
1175
|
-
| Tier | Behavior | When | Example codes |
|
|
1176
|
-
| ---- | -------------- | ------------------------------ | ------------------------ |
|
|
1177
|
-
| 0 | Silent | Spec-compliant input | (none) |
|
|
1178
|
-
| 1 | Auto-handled | Trivial deviation, no warning | Trailing whitespace tidy |
|
|
1179
|
-
| 2 | Warning | Recoverable deviation | `MLLP_FRAMING_STRIPPED` |
|
|
1180
|
-
| 3 | Fatal (always) | Unrecoverable structural error | `NO_MSH_SEGMENT` |
|
|
127
|
+
## Support
|
|
1181
128
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
```ts
|
|
1185
|
-
import { parseHL7 } from "@cosyte/hl7";
|
|
1186
|
-
|
|
1187
|
-
const msg = parseHL7(raw);
|
|
1188
|
-
for (const w of msg.warnings) {
|
|
1189
|
-
console.log(`${w.code} at segment ${w.position.segmentIndex}: ${w.message}`);
|
|
1190
|
-
}
|
|
1191
|
-
```
|
|
1192
|
-
|
|
1193
|
-
The full list of Tier-2 codes lives in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.UNKNOWN_SEGMENT` (and friends) for typo-free comparisons.
|
|
1194
|
-
|
|
1195
|
-
Need zero tolerance instead? `parseHL7(raw, { strict: true })` escalates every Tier-2 deviation to a thrown `Hl7ParseError`. Use strict in CI validators; leave it off for production ingestion.
|
|
1196
|
-
|
|
1197
|
-
The 4 Tier-3 fatal codes (`NO_MSH_SEGMENT`, `MSH_TOO_SHORT`, `INVALID_ENCODING_CHARACTERS`, `EMPTY_INPUT`) always throw regardless of mode. They represent inputs the parser can't meaningfully recover from.
|
|
129
|
+
Questions, bug reports, and quirk sightings all start on the [issue tracker](https://github.com/cosyte/hl7/issues), and so does any refactor large enough to want discussion before a pull request.
|
|
1198
130
|
|
|
1199
131
|
---
|
|
1200
132
|
|
|
1201
|
-
##
|
|
1202
|
-
|
|
1203
|
-
The library throws exactly three error types, all exported from the package barrel.
|
|
133
|
+
## Security
|
|
1204
134
|
|
|
1205
|
-
|
|
135
|
+
Report a vulnerability privately, as the [security policy](https://github.com/cosyte/.github/blob/main/SECURITY.md) describes, never in a public issue.
|
|
1206
136
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
```ts
|
|
1210
|
-
import { parseHL7, Hl7ParseError, FATAL_CODES } from "@cosyte/hl7";
|
|
1211
|
-
|
|
1212
|
-
try {
|
|
1213
|
-
parseHL7("");
|
|
1214
|
-
} catch (err) {
|
|
1215
|
-
if (err instanceof Hl7ParseError) {
|
|
1216
|
-
console.log(err.code); // "EMPTY_INPUT"
|
|
1217
|
-
console.log(err.position); // { segmentIndex: 0 }
|
|
1218
|
-
console.log(err.snippet); // "" (first 40 chars of input, for logging)
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Narrow exhaustively with the FATAL_CODES registry:
|
|
1223
|
-
if (err instanceof Hl7ParseError && err.code === FATAL_CODES.NO_MSH_SEGMENT) {
|
|
1224
|
-
// ...
|
|
1225
|
-
}
|
|
1226
|
-
```
|
|
137
|
+
### PHI and safety
|
|
1227
138
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
**`snippet` is the only field carrying input verbatim and unfiltered** (capped at 40 characters plus an ellipsis, but not shape-checked in any way), so redact it first.
|
|
1231
|
-
|
|
1232
|
-
`message` is bounded but not absolutely content-free, which is worth stating because `message` is what a logger prints by default and what `stack` embeds. A token taken from the input is echoed only when it matches the form the spec defines for it: a three-character segment identifier, an MSH-9 type, an MSH-12 version, or a charset label the closed Table 0211 actually contains. Anything else becomes `<withheld>`. So a message cannot carry a field's value, but it can carry a residue of up to three characters when a malformed line happens to look like a segment identifier. The other shapes are narrower in practice than their patterns allow, because the library only ever feeds them registry-matched values; the patterns themselves admit more (a message type up to 26 characters, a version up to 14), which matters only if you construct warnings yourself.
|
|
1233
|
-
|
|
1234
|
-
Under `{ strict: true }` an escalated Tier-2 warning is thrown as an `Hl7ParseError` carrying that same bounded message, plus a `snippet` of the first 40 characters of the input rather than of the deviation's own segment.
|
|
1235
|
-
|
|
1236
|
-
### `Hl7ParseWarning`
|
|
1237
|
-
|
|
1238
|
-
Tier-2 deviations: plain data, not thrown. Accumulated on `msg.warnings` and delivered to any `onWarning` callback in parse order. See the iteration example in [Real-World Tolerance](#real-world-tolerance).
|
|
1239
|
-
|
|
1240
|
-
```ts
|
|
1241
|
-
import type { Hl7ParseWarning, WarningCode } from "@cosyte/hl7";
|
|
1242
|
-
|
|
1243
|
-
function label(w: Hl7ParseWarning): string {
|
|
1244
|
-
const c: WarningCode = w.code;
|
|
1245
|
-
switch (c) {
|
|
1246
|
-
case "MLLP_FRAMING_STRIPPED":
|
|
1247
|
-
return "framed input";
|
|
1248
|
-
case "UNKNOWN_SEGMENT":
|
|
1249
|
-
return `unknown: ${w.message}`;
|
|
1250
|
-
default:
|
|
1251
|
-
return c;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
```
|
|
1255
|
-
|
|
1256
|
-
Use the `WarningCode` union + `switch` for exhaustive handling: the type system catches missing cases if you enable `switch-exhaustiveness-check`.
|
|
1257
|
-
|
|
1258
|
-
### `ProfileDefinitionError`
|
|
1259
|
-
|
|
1260
|
-
Thrown by `defineProfile()` when the options are structurally invalid. Covers the four failure modes locked by the profile system:
|
|
1261
|
-
|
|
1262
|
-
```ts
|
|
1263
|
-
import { defineProfile, ProfileDefinitionError } from "@cosyte/hl7";
|
|
1264
|
-
|
|
1265
|
-
try {
|
|
1266
|
-
defineProfile({
|
|
1267
|
-
name: "broken",
|
|
1268
|
-
customSegments: {
|
|
1269
|
-
AB: { fields: { foo: 1 } }, // bad: segment name must match /^Z[A-Z0-9]{2}$/ or similar
|
|
1270
|
-
},
|
|
1271
|
-
});
|
|
1272
|
-
} catch (err) {
|
|
1273
|
-
if (err instanceof ProfileDefinitionError) {
|
|
1274
|
-
console.log(err.profileName); // "broken"
|
|
1275
|
-
console.log(err.message); // actionable diagnostic
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
```
|
|
1279
|
-
|
|
1280
|
-
The four cases that throw `ProfileDefinitionError`:
|
|
1281
|
-
|
|
1282
|
-
1. **Missing or non-string `name`**: every profile must identify itself.
|
|
1283
|
-
2. **Malformed `customSegments`**: segment name must pass the Z-segment regex; each field entry must map to a positive 1-indexed integer; no duplicate field names across merged segments.
|
|
1284
|
-
3. **Unsupported `dateFormats` tokens**: tokens must be drawn from `SUPPORTED_DATE_TOKENS` (re-exported from the package barrel for introspection).
|
|
1285
|
-
4. **Unknown option keys**: the options bag is closed; typos throw with "did you mean" hints.
|
|
1286
|
-
|
|
1287
|
-
All three error types (and the `FATAL_CODES` / `WARNING_CODES` registries, and the `SUPPORTED_DATE_TOKENS` set) are top-level exports: no internal reaching required.
|
|
1288
|
-
|
|
1289
|
-
---
|
|
139
|
+
HL7 v2 messages carry patient data, so what this library does with the bytes you hand it is part of its contract.
|
|
1290
140
|
|
|
1291
|
-
|
|
141
|
+
**It does not log.** There is no logger, no debug channel, and no `console` call anywhere in the library: diagnostics are returned to you, never printed. Tolerated deviations come back as `Hl7ParseWarning` values carrying a stable code, a bounded message, and a position (`segmentIndex`, `fieldIndex`), never the field value that triggered them, so a warning is safe to log as-is.
|
|
1292
142
|
|
|
1293
|
-
|
|
143
|
+
**One field is an exception, and it is deliberate.** `Hl7ParseError.snippet` carries up to 40 characters of your input verbatim and unredacted so a fatal parse failure is actionable. It is the only field that echoes input without filtering, the library does not redact it, and it is the first thing to strip if your compliance posture requires it. `Hl7ParseError.message` is bounded rather than absolutely content-free: it echoes a token only where the token matches a shape the spec defines (a three-character segment identifier, a message type, a version, a known charset label) and withholds anything else.
|
|
1294
144
|
|
|
1295
|
-
|
|
145
|
+
**It retains nothing you do not hold.** Parsing returns an `Hl7Message` and the message content lives on that object and nowhere else. There is no cache, no pool, and no module-level store of message data, so the content is released when you drop your reference to it. The one piece of process-wide state the library keeps is an optional registered default profile, which holds parsing configuration, not patient data.
|
|
1296
146
|
|
|
1297
|
-
|
|
147
|
+
**It writes nothing and sends nothing.** No file is opened, no directory is written, and no network connection is made: the library imports only `node:buffer` and `node:crypto` from the standard library and has zero runtime dependencies. `parseStream` reads a stream you supply and opens nothing itself. `toString()`, `toJSON()`, and `prettyPrint()` hand the message content back to you by design; where it goes next is your call, and `prettyPrint()` in particular is a debug view of real patient data, not a redacted one.
|
|
1298
148
|
|
|
1299
|
-
|
|
1300
|
-
- **HL7 v3 and CDA**: different spec family entirely; not in our plans.
|
|
1301
|
-
- **FHIR conversion**: [`@cosyte/transform`](https://github.com/cosyte/transform) converts a parsed message to FHIR R4; this library does not.
|
|
1302
|
-
- **Exhaustive coded-value validation**: we validate structure, not every HL7 table. Integrate a domain validator (e.g. your LIS's internal code registry) for that.
|
|
149
|
+
**What stays yours.** Transport security, storage, retention and audit, redaction before anything reaches a log or an error tracker, and access control are all the consuming application's. A parser cannot make a system HIPAA-compliant, and this one does not claim to.
|
|
1303
150
|
|
|
1304
151
|
---
|
|
1305
152
|
|
|
@@ -1307,20 +154,18 @@ What lands next starts as a quirk sighting or a real-world edge case on the [iss
|
|
|
1307
154
|
|
|
1308
155
|
Vendor-quirk fixtures, profile improvements, and standalone profile packages are all welcome. The more real-world edge cases the test suite covers, the more robust the parser gets. Every published profile package is a signal of adoption and a contribution back.
|
|
1309
156
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
See [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup, how to file an issue, and how to submit a PR.
|
|
157
|
+
The organization's [contributing guide](https://github.com/cosyte/.github/blob/main/CONTRIBUTING.md) and [code of conduct](https://github.com/cosyte/.github/blob/main/CODE_OF_CONDUCT.md) apply here. [CONTRIBUTING.md](https://github.com/cosyte/hl7/blob/main/CONTRIBUTING.md) adds what is specific to this repository: dev setup, the gate, and how to submit a PR.
|
|
1313
158
|
|
|
1314
159
|
---
|
|
1315
160
|
|
|
1316
161
|
## Trademarks
|
|
1317
162
|
|
|
1318
163
|
Epic, Cerner, MEDITECH, athenahealth, LabCorp, Quest Diagnostics, Visage (Visage Imaging), and Philips (Vue PACS) are trademarks of their respective owners. Cosyte is not affiliated with, endorsed by, or
|
|
1319
|
-
sponsored by any of them: the names identify the systems whose real-world message quirks the built-in profiles accommodate. See [TRADEMARKS.md](
|
|
164
|
+
sponsored by any of them: the names identify the systems whose real-world message quirks the built-in profiles accommodate. See [TRADEMARKS.md](https://github.com/cosyte/hl7/blob/main/TRADEMARKS.md).
|
|
1320
165
|
|
|
1321
166
|
## License
|
|
1322
167
|
|
|
1323
|
-
MIT,
|
|
168
|
+
MIT, Copyright (c) 2026 Cosyte LLC. See [LICENSE](https://github.com/cosyte/hl7/blob/main/LICENSE).
|
|
1324
169
|
|
|
1325
170
|
---
|
|
1326
171
|
|