@cosyte/hl7 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -11,10 +11,10 @@
11
11
 
12
12
  [![npm version](https://img.shields.io/npm/v/@cosyte/hl7.svg)](https://www.npmjs.com/package/@cosyte/hl7)
13
13
  [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/hl7/ci.yml?branch=main&label=CI)](https://github.com/cosyte/hl7/actions/workflows/ci.yml)
14
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/cosyte/hl7/blob/main/LICENSE)
15
15
  [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
16
16
 
17
- A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript. Optimised for the 10% of HL7 you actually use, with the other 90% still one accessor away when you need it.
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
- - [PHI and safety](#phi-and-safety)
28
- - [Features](#features)
29
- - [HL7 in 90 seconds](#hl7-in-90-seconds)
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
- `0.1.0`. 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.
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: [Roadmap](#roadmap) below names no future work, only what is permanently out of scope. `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.
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`. 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
+ Requires Node.js 22 or later, tested on 22 and 24 (`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,1198 +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, normalises 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.
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 every `@cosyte` parser,
669
- so importing two of them into one file collides. Alias on import:
670
-
671
- ```ts
672
- import { parseDtm, toISO as hl7ToISO } from "@cosyte/hl7";
673
- import { toISO as x12ToISO } from "@cosyte/x12";
674
-
675
- hl7ToISO(parseDtm("19880705")); // "1988-07-05"
676
- // x12ToISO does the same for an X12 date value: same three names, same semantics.
677
- ```
678
-
679
- ...or namespace-import, which keeps the package of origin visible at every call site:
680
-
681
- ```ts
682
- import * as hl7 from "@cosyte/hl7";
683
- import * as x12 from "@cosyte/x12";
684
-
685
- hl7.toISO(hl7.parseDtm("19880705")); // "1988-07-05"
686
- // x12.toISO(...) is the same call, against an X12 date value.
687
- ```
688
-
689
- #### Non-standard timestamp formats
690
-
691
- HL7's canonical `YYYYMMDDHHmmss` parses with zero warnings. For everything else (vendor-quirky
692
- `MM/DD/YYYY`, ISO `YYYY-MM-DD`, legacy `YYYYMMDD HHmm`), tell the parser what your sender writes
693
- using the `dateFormats` option. Each format is tried in order, the first match wins, and it
694
- populates the same fidelity `TS`.
695
-
696
- ```ts
697
- import { parseHL7 } from "@cosyte/hl7";
698
-
699
- const msg = parseHL7(raw, {
700
- dateFormats: ["MM/DD/YYYY HH:mm:ss", "MM/DD/YYYY", "YYYY-MM-DD"],
701
- });
702
-
703
- console.log(msg.meta.timestamp?.matchedFormat); // e.g. "MM/DD/YYYY": which declared format won
704
- console.log(msg.patient?.dateOfBirth?.matchedFormat); // the same list reaches PID-7
705
- ```
706
-
707
- **The formats you declare reach every datetime the library returns**, not just the message header
708
- timestamp: `patient.dateOfBirth`, `visit.admitDateTime` / `dischargeDateTime`, each
709
- `observations()` entry's `observedDateTime` and its `TS`/`DT` typed value, `allergies()`
710
- onset dates, `diagnoses()` date/times, `insurance()` effective and expiration dates,
711
- `immunizations()` administered and expiration dates, `charges()` transaction dates,
712
- `documents()` activity date/times, order and medication `timings` (a `TQ1` segment and the legacy
713
- embedded `TQ` alike), and `appointments()` start and end times.
714
-
715
- `matchedFormat` is how you tell which answer you got: it names the declared format that matched,
716
- and is absent when the value was canonical HL7 and parsed strictly. There is no parse warning for
717
- either case.
718
-
719
- **Only the formats you declare are tried on those fields.** A date the parser was not told about
720
- stays `valid: false` with its `raw` text intact, rather than being guessed at: guessing is how a
721
- day-first `05/07/1988` becomes a confident May 7 on a date of birth, and a plausible wrong date is
722
- worse than a missing one. `BUILTIN_DATE_FALLBACKS` (ISO-8601 and the US-order slash forms) is a
723
- last resort for `msg.meta.timestamp` alone and never runs on a typed datetime field.
724
-
725
- 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.
726
-
727
- 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.
728
-
729
- #### Day-first vs month-first: the parser refuses to guess
730
-
731
- `05/07/1988` is 5 July to a day-first sender and May 7 to a month-first one. Both are real calendar
732
- dates, and nothing in the message says which was meant. When no format has been declared, the
733
- parser resolves **neither**: `msg.meta.timestamp` comes back `valid: false` carrying an `ambiguity`
734
- report that names the raw value and both readings, so a wrong date never reaches your code silently.
735
-
736
- ```ts
737
- import { parseHL7, AMBIGUOUS_DATE_ORDER } from "@cosyte/hl7";
738
-
739
- const ts = parseHL7(raw).meta.timestamp; // MSH-7 was "05/07/1988"
740
-
741
- if (ts?.ambiguity?.code === AMBIGUOUS_DATE_ORDER) {
742
- console.log(ts.ambiguity.raw); // "05/07/1988"
743
- console.log(ts.ambiguity.candidates[0]); // { format: "MM/DD/YYYY", month: 5, day: 7, isoDate: "1988-05-07" }
744
- console.log(ts.ambiguity.candidates[1]); // { format: "DD/MM/YYYY", month: 7, day: 5, isoDate: "1988-07-05" }
745
- }
746
- ```
747
-
748
- Declaring the sender's order is the fix, and it is one line. A declared format is tried ahead of the
749
- built-ins, so the value resolves and no ambiguity is reported:
750
-
751
- ```ts
752
- const dayFirst = parseHL7(raw, { dateFormats: ["DD/MM/YYYY"] });
753
- console.log(dayFirst.meta.timestamp?.month); // 7: 5 July 1988
754
-
755
- const monthFirst = parseHL7(raw, { dateFormats: ["MM/DD/YYYY"] });
756
- console.log(monthFirst.meta.timestamp?.month); // 5: May 7 1988
757
- ```
758
-
759
- Only genuinely two-way values are refused. `07/25/1988` has one reading (there is no month 25) and
760
- still resolves as July 25. `05/05/1988` has two readings that agree and still resolves. Strict HL7
761
- timestamps, ISO-8601 values and `YYYY-MM-DD` values are untouched. Ambiguity is also a different fact
762
- from malformed input: a value that is illegal under both readings reports no timestamp and no
763
- ambiguity.
764
-
765
- The report belongs to `msg.meta.timestamp`, because it is the built-in list that produces the second
766
- reading and that list runs there and nowhere else. On a typed datetime field, an undeclared
767
- `05/07/1988` is simply `valid: false` with its `raw` intact and no `ambiguity`: nothing tried to read
768
- it, so there was no guess to refuse. Declaring the order resolves it on every field at once.
769
-
770
- ### Stripping MLLP framing
771
-
772
- 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.
773
-
774
- ```ts
775
- import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
776
-
777
- const msg = parseHL7(mllpFramed);
778
- const framed = msg.warnings.some((w) => w.code === WARNING_CODES.MLLP_FRAMING_STRIPPED);
779
- console.log(framed); // true if input had VT/FS/CR framing
780
-
781
- // Disable preprocessing if your input is guaranteed framing-free:
782
- parseHL7(raw, { stripMllpFraming: false });
783
- ```
784
-
785
- 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).
786
-
787
- ### Batch files
788
-
789
- 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.
790
-
791
- ```ts
792
- import { splitBatch } from "@cosyte/hl7";
793
-
794
- const { messages, warnings } = splitBatch(rawBatchFile);
795
-
796
- for (const entry of messages) {
797
- if (entry.ok) {
798
- handle(entry.message); // a fully-parsed Hl7Message
799
- } else {
800
- quarantine(entry.raw, entry.error.code); // isolated, not dropped
801
- }
802
- }
803
-
804
- // Batch-level warnings carry counts/positions only, never PHI.
805
- for (const w of warnings) {
806
- // BATCH_COUNT_MISMATCH: declared BTS-1/FTS-1 ≠ actual (tail still returned)
807
- // BATCH_MISSING_TRAILER: a BHS/FHS header with no matching BTS/FTS
808
- }
809
- ```
810
-
811
- 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.
812
-
813
- ### Streaming large files (`parseStream`)
814
-
815
- `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.
816
-
817
- ```ts
818
- import { createReadStream } from "node:fs";
819
- import { parseStream, WARNING_CODES } from "@cosyte/hl7";
820
-
821
- for await (const entry of parseStream(createReadStream("elr-feed.hl7"))) {
822
- if (entry.ok) {
823
- handle(entry.message); // released before the next is read: bounded memory
824
- } else {
825
- quarantine(entry.raw, entry.error.code); // isolated; the tail still streams
826
- }
827
- // Stream-level (not per-message) diagnostics, never PHI:
828
- for (const w of entry.streamWarnings) {
829
- // UNTERMINATED_STREAM_MESSAGE: the final message had no terminator (truncated feed?)
830
- }
831
- }
832
- ```
833
-
834
- 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).
835
-
836
- ### Detect message type
837
-
838
- `msg.meta` exposes MSH-9 pre-decomposed into its three components: use them instead of parsing the raw string.
839
-
840
- ```ts
841
- import { parseHL7 } from "@cosyte/hl7";
842
-
843
- const msg = parseHL7(raw);
844
-
845
- console.log(msg.meta.type); // "ADT^A01"
846
- console.log(msg.meta.messageCode); // "ADT"
847
- console.log(msg.meta.triggerEvent); // "A01"
848
- console.log(msg.meta.messageStructure); // "ADT_A01" (if present in MSH-9.3)
849
-
850
- if (msg.meta.messageCode === "ORU") {
851
- // branch for observation results
852
- }
853
- ```
854
-
855
- 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.
856
-
857
- ### Narrow a message to its type
858
-
859
- `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.
860
-
861
- ```ts
862
- import { parseHL7 } from "@cosyte/hl7";
863
-
864
- const msg = parseHL7(raw);
865
-
866
- if (msg.is("ADT^A01")) {
867
- const code: "ADT" = msg.meta.messageCode; // literal, and no longer optional
868
- const event: "A01" = msg.meta.triggerEvent;
869
- console.log(code, event);
870
-
871
- const pid = msg.part("PID"); // Segment | undefined: first PID, or none
872
- const evn = msg.parts("EVN"); // readonly Segment[]: every EVN, possibly empty
873
- console.log(pid?.field(3).value, evn.length);
874
-
875
- // msg.part("OBX"); // does not compile: ADT^A01 does not require OBX
876
- console.log(msg.segments("OBX").length); // the base accessor still takes any name
877
- }
878
- ```
879
-
880
- 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.
881
-
882
- 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.
883
-
884
- 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.
885
-
886
- ### Spot a truncated or misrouted message
887
-
888
- 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.
889
-
890
- ```ts
891
- import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
892
-
893
- const msg = parseHL7(raw); // ORU^R01 with only MSH + PID
894
-
895
- console.log(msg.structure.recognized); // true
896
- console.log(msg.structure.missingSegments); // ["OBR"]
897
- console.log(msg.structure.structureIds); // ["ORU_R01-A", "ORU_R01-B", ...]
898
-
899
- // The same finding also surfaces as an additive Tier-2 warning, so a
900
- // channel can route on it without inspecting `structure`:
901
- if (msg.warnings.some((w) => w.code === WARNING_CODES.MISSING_EXPECTED_GROUP)) {
902
- // quarantine / alert: the message is missing an expected segment
903
- }
904
- ```
905
-
906
- 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.
907
-
908
- 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.
909
-
910
- ### Check a message against HL7's published shape (`validateMessageStructure`)
911
-
912
- `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.
913
-
914
- ```ts
915
- import { parseHL7, validateMessageStructure } from "@cosyte/hl7";
916
-
917
- const result = validateMessageStructure(parseHL7(raw)); // ADT^A01 with PV1 before PID
918
-
919
- console.log(result.validated); // true
920
- console.log(result.structureId); // "ADT_A01-A": the variant the findings are against
921
- console.log(result.structureIds); // the whole variant family that was considered
922
- for (const f of result.findings) console.log(f.severity, f.code, f.locus.segment, f.message);
923
- // e.g. 'error STRUCTURE_SEGMENT_OUT_OF_ORDER PID Segment "PID" (occurrence 0) appears where published structure ADT_A01-A does not allow it.'
924
- ```
925
-
926
- 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.
927
-
928
- 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.
929
-
930
- **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.
931
-
932
- > **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).
933
-
934
- ### Validate against your own conformance profile (`validateAgainstProfile`)
935
-
936
- `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.
937
-
938
- ```ts
939
- import { parseHL7, validateAgainstProfile, type ConformanceProfile } from "@cosyte/hl7";
940
-
941
- // A profile YOU author: an example, NOT an attestation of conformance.
942
- const profile: ConformanceProfile = {
943
- name: "our-adt-intake",
944
- segments: [
945
- {
946
- segment: "PID",
947
- usage: "R",
948
- fields: [
949
- { field: 3, name: "Patient Identifiers", usage: "R", cardinality: { min: 1, max: 1 } },
950
- { field: 8, name: "Administrative Sex", usage: "RE", valueSet: ["M", "F", "U"] },
951
- ],
952
- },
953
- { segment: "ZZZ", usage: "X" }, // no local Z-segments permitted
954
- ],
955
- };
956
-
957
- const { findings } = validateAgainstProfile(parseHL7(raw), profile);
958
- for (const f of findings) console.log(f.severity, f.code, f.message);
959
- // e.g. "error PROFILE_VALUE_NOT_IN_SET PID-8 component 1 value is not in the profile value set (3 permitted codes)."
960
- ```
961
-
962
- 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_.
963
-
964
- 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.
965
-
966
- ```ts
967
- // "PID-8 is Required when a date of birth was sent, Not-permitted when it was not."
968
- {
969
- field: 8,
970
- usage: "C(R/X)",
971
- condition: { location: { segment: "PID", field: 7 }, presence: "is valued" },
972
- }
973
- ```
974
-
975
- 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.
976
-
977
- > **"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).
978
-
979
- ### Pretty-print for logs
980
-
981
- `msg.prettyPrint()` returns a multi-line, labeled view of the positional tree: useful for dev-time debugging and log snapshots.
982
-
983
- ```ts
984
- import { parseHL7 } from "@cosyte/hl7";
985
-
986
- const msg = parseHL7(raw);
987
- console.log(msg.prettyPrint());
988
- // MSH-1 (field separator): |
989
- // MSH-2 (encoding chars): ^~\&
990
- // MSH-3 (sending app): CLINIC
991
- // ...
992
- // PID-5 (name):
993
- // .1 = Smith
994
- // .2 = Jane
995
- // ...
996
- ```
997
-
998
- 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.
999
112
 
1000
113
  ---
1001
114
 
1002
- ## Profiles
1003
-
1004
- 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.
1005
-
1006
- ### Authoring a profile
1007
-
1008
- `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)).
1009
-
1010
- ```ts
1011
- import { defineProfile, type CustomSegmentDefinition } from "@cosyte/hl7";
1012
-
1013
- const zdp: CustomSegmentDefinition = {
1014
- fields: { departmentCode: 3, departmentName: 4 },
1015
- };
1016
-
1017
- const myhospital = defineProfile({
1018
- name: "myhospital",
1019
- description: "Custom Z-segments for MyHospital ADT feed",
1020
- dateFormats: ["MM/DD/YYYY HH:mm:ss"],
1021
- customSegments: { ZDP: zdp },
1022
- });
1023
- ```
1024
-
1025
- 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).
1026
-
1027
- ### Naming fields on standard segments
1028
-
1029
- `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.
1030
-
1031
- ```ts
1032
- import { defineProfile, parseHL7 } from "@cosyte/hl7";
1033
-
1034
- const site = defineProfile({
1035
- name: "myhospital-adt",
1036
- segmentOverrides: {
1037
- PID: { fields: { siteMrn: 19 } },
1038
- AL1: { fields: { localSeverityCode: 4 } },
1039
- RXA: { fields: { doseQualifier: 6 } },
1040
- },
1041
- });
1042
-
1043
- const msg = parseHL7(raw, site);
1044
- msg.part("PID")?.get("siteMrn")?.value; // the value at PID-19
1045
- msg.part("AL1")?.get("localSeverityCode")?.value; // the value at AL1-4
1046
- ```
1047
-
1048
- **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.
1049
-
1050
- **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`.
1051
-
1052
- **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:
1053
-
1054
- ```ts
1055
- const parent = defineProfile({
1056
- name: "parent",
1057
- segmentOverrides: { PID: { fields: { parentAlias: 19, countyCode: 12 } } },
1058
- });
1059
-
1060
- const child = defineProfile({
1061
- name: "child",
1062
- extends: parent,
1063
- segmentOverrides: { PID: { fields: { childAlias: 19 } } },
1064
- });
1065
-
1066
- // child.segmentOverrides.PID.fields === { childAlias: 19, countyCode: 12 }
1067
- ```
1068
-
1069
- **What `defineProfile()` refuses, at definition time**, throwing `ProfileDefinitionError` and returning no profile:
1070
-
1071
- | Declaration | Why it is refused |
1072
- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1073
- | a Z-segment key (`ZPI`) | that is a custom segment: the error points you at the `customSegments` option |
1074
- | 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 |
1075
- | a position that is not a positive integer (`0`, `-1`, `2.5`, `"3"`) | the error names the profile, the segment and the field |
1076
- | a misspelled option key (`segmentOverride`) | reported with `segmentOverrides` as the suggestion |
1077
-
1078
- An empty map (`segmentOverrides: {}`), or a declared segment with an empty field map, is accepted and behaves exactly like declaring nothing.
1079
-
1080
- #### Limitations
1081
-
1082
- **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:
1083
-
1084
- - **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.
1085
- - **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.
1086
- - **change serialization.** `toString()` is byte-for-byte what it was, `toJSON()` is unchanged, and the message still round-trips verbatim.
1087
- - **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.
1088
- - **touch the write path.** `setField`, `setComposite`, `buildMessage` and the typed builders take positions, not names.
1089
-
1090
- Parse one message with the overrides and again without them and every one of those observables is equal. Only the new names differ.
1091
-
1092
- ### Extending profiles
1093
-
1094
- Use `extends` to layer profiles. Single parent or array of parents, both supported.
1095
-
1096
- ```ts
1097
- import { defineProfile, profiles } from "@cosyte/hl7";
1098
-
1099
- const myEpic = defineProfile({
1100
- name: "my-epic",
1101
- extends: profiles.epic,
1102
- customSegments: {
1103
- ZPR: { fields: { providerId: 1 } },
1104
- },
1105
- });
1106
-
1107
- const combined = defineProfile({
1108
- name: "epic-plus-lab",
1109
- extends: [profiles.epic, profiles.genericLab],
1110
- });
1111
- ```
1112
-
1113
- Lineage is recorded on the result (`profile.lineage`) in parent-first order, ending with the profile's own name.
1114
-
1115
- ### Merge semantics
1116
-
1117
- When `extends` resolves parents, fields are merged per-key with the following rules:
1118
-
1119
- - **Scalars** (`description`): later layers overwrite earlier ones.
1120
- - **Arrays** (`dateFormats`, `lineage`): concatenate + dedupe, preserving first-seen order.
1121
- - **`customSegments` map**: deep-merge per key; same-segment-name in two parents reconciles positional fields.
1122
- - **`segmentOverrides` map**: the same reducer, on the standard-segment declarations. See [Naming fields on standard segments](#naming-fields-on-standard-segments).
1123
- - **`onWarning` handlers**: compose into a chain, invoked in lineage order (parents before children). Errors thrown by one handler do not stop subsequent handlers.
1124
- - **`name`**: never inherited, always the profile's own.
115
+ ## Documentation
1125
116
 
1126
- The merge is validated post-hoc: duplicate field names across merged segments throw `ProfileDefinitionError` up front, not at parse time.
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:
1127
118
 
1128
- ### Inspecting a profile
1129
-
1130
- Every `defineProfile()` result carries a `.describe()` method and an introspectable `lineage` array: useful for debugging which layers contributed what.
1131
-
1132
- ```ts
1133
- import { defineProfile, profiles } from "@cosyte/hl7";
1134
-
1135
- const p = defineProfile({
1136
- name: "my-epic",
1137
- extends: profiles.epic,
1138
- customSegments: { ZPR: { fields: { providerId: 1 } } },
1139
- });
1140
-
1141
- console.log(p.name); // "my-epic"
1142
- console.log(p.lineage); // ["epic", "my-epic"]
1143
- console.log(Object.keys(p.customSegments ?? {})); // ["ZDP", "ZRS", "ZPR"]
1144
- console.log(p.describe?.()); // multi-line summary
1145
- ```
1146
-
1147
- ### Publishing a profile
1148
-
1149
- 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.
1150
-
1151
- ### Built-in profiles
1152
-
1153
- Eight profiles ship in the box, reachable via the `profiles` namespace:
1154
-
1155
- - `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.
1156
- - `profiles.cerner`: Cerner Millennium outbound. Handles Cerner-idiomatic date formats and common Z-segments from Millennium ADT feeds.
1157
- - `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.
1158
- - `profiles.athena`: athenahealth. Ambulatory-oriented, ISO-leaning date formats.
1159
- - `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.
1160
- - `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.
1161
- - `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.
1162
- - `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.
1163
-
1164
- 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.
1165
124
 
1166
125
  ---
1167
126
 
1168
- ## Real-World Tolerance
1169
-
1170
- 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.
1171
-
1172
- Every deviation the parser encounters is classified into one of four tiers:
1173
-
1174
- | Tier | Behavior | When | Example codes |
1175
- | ---- | -------------- | ------------------------------ | ------------------------ |
1176
- | 0 | Silent | Spec-compliant input | (none) |
1177
- | 1 | Auto-handled | Trivial deviation, no warning | Trailing whitespace tidy |
1178
- | 2 | Warning | Recoverable deviation | `MLLP_FRAMING_STRIPPED` |
1179
- | 3 | Fatal (always) | Unrecoverable structural error | `NO_MSH_SEGMENT` |
127
+ ## Support
1180
128
 
1181
- Tier-2 warnings are plain data attached to `msg.warnings`. Every warning carries a stable string `code`, a human-readable `message`, and a `position` with 1-indexed `segmentIndex`/`fieldIndex`/etc. so you can programmatically react to specific deviations:
1182
-
1183
- ```ts
1184
- import { parseHL7 } from "@cosyte/hl7";
1185
-
1186
- const msg = parseHL7(raw);
1187
- for (const w of msg.warnings) {
1188
- console.log(`${w.code} at segment ${w.position.segmentIndex}: ${w.message}`);
1189
- }
1190
- ```
1191
-
1192
- 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.
1193
-
1194
- 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.
1195
-
1196
- 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.
1197
130
 
1198
131
  ---
1199
132
 
1200
- ## Error Handling
1201
-
1202
- The library throws exactly three error types, all exported from the package barrel.
133
+ ## Security
1203
134
 
1204
- ### `Hl7ParseError`
135
+ Report a vulnerability privately, as the [security policy](https://github.com/cosyte/.github/blob/main/SECURITY.md) describes, never in a public issue.
1205
136
 
1206
- Thrown by `parseHL7` when the input hits one of the 4 Tier-3 fatal codes (see above). Carries positional context plus a short snippet taken from the start of the input (up to 40 characters, plus an ellipsis when truncated).
1207
-
1208
- ```ts
1209
- import { parseHL7, Hl7ParseError, FATAL_CODES } from "@cosyte/hl7";
1210
-
1211
- try {
1212
- parseHL7("");
1213
- } catch (err) {
1214
- if (err instanceof Hl7ParseError) {
1215
- console.log(err.code); // "EMPTY_INPUT"
1216
- console.log(err.position); // { segmentIndex: 0 }
1217
- console.log(err.snippet); // "" (first 40 chars of input, for logging)
1218
- }
1219
- }
1220
-
1221
- // Narrow exhaustively with the FATAL_CODES registry:
1222
- if (err instanceof Hl7ParseError && err.code === FATAL_CODES.NO_MSH_SEGMENT) {
1223
- // ...
1224
- }
1225
- ```
137
+ ### PHI and safety
1226
138
 
1227
- `Hl7ParseError.snippet` may contain PHI when parsing real clinical messages. The library does NOT redact it. Redact it at your call site if compliance demands it.
1228
-
1229
- **`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.
1230
-
1231
- `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.
1232
-
1233
- 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.
1234
-
1235
- ### `Hl7ParseWarning`
1236
-
1237
- 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).
1238
-
1239
- ```ts
1240
- import type { Hl7ParseWarning, WarningCode } from "@cosyte/hl7";
1241
-
1242
- function label(w: Hl7ParseWarning): string {
1243
- const c: WarningCode = w.code;
1244
- switch (c) {
1245
- case "MLLP_FRAMING_STRIPPED":
1246
- return "framed input";
1247
- case "UNKNOWN_SEGMENT":
1248
- return `unknown: ${w.message}`;
1249
- default:
1250
- return c;
1251
- }
1252
- }
1253
- ```
1254
-
1255
- Use the `WarningCode` union + `switch` for exhaustive handling: the type system catches missing cases if you enable `switch-exhaustiveness-check`.
1256
-
1257
- ### `ProfileDefinitionError`
1258
-
1259
- Thrown by `defineProfile()` when the options are structurally invalid. Covers the four failure modes locked by the profile system:
1260
-
1261
- ```ts
1262
- import { defineProfile, ProfileDefinitionError } from "@cosyte/hl7";
1263
-
1264
- try {
1265
- defineProfile({
1266
- name: "broken",
1267
- customSegments: {
1268
- AB: { fields: { foo: 1 } }, // bad: segment name must match /^Z[A-Z0-9]{2}$/ or similar
1269
- },
1270
- });
1271
- } catch (err) {
1272
- if (err instanceof ProfileDefinitionError) {
1273
- console.log(err.profileName); // "broken"
1274
- console.log(err.message); // actionable diagnostic
1275
- }
1276
- }
1277
- ```
1278
-
1279
- The four cases that throw `ProfileDefinitionError`:
1280
-
1281
- 1. **Missing or non-string `name`**: every profile must identify itself.
1282
- 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.
1283
- 3. **Unsupported `dateFormats` tokens**: tokens must be drawn from `SUPPORTED_DATE_TOKENS` (re-exported from the package barrel for introspection).
1284
- 4. **Unknown option keys**: the options bag is closed; typos throw with "did you mean" hints.
1285
-
1286
- 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.
1287
-
1288
- ---
139
+ HL7 v2 messages carry patient data, so what this library does with the bytes you hand it is part of its contract.
1289
140
 
1290
- ## Roadmap
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.
1291
142
 
1292
- Nothing is queued for a future release. Three capabilities a reader might expect to find named here are available today. Batch files too large to buffer are parsed incrementally by [`parseStream`](#streaming-large-files-parsestream), which yields the messages as an async iterable. JSON Schema and Zod descriptions of what `toJSON()` returns come from `emitMessageSchema`; Zod arrives as TypeScript source text to paste into your own project, so this package still has zero runtime dependencies (see the [schema emission notes](./docs-content/schema-emission.md)). A profile's declared field names are carried into the type system, so `seg.get("departmentCode")` on a declared segment is checked against that profile's names instead of widening to `string | undefined`. All three are exported, and all three are covered by the stability claim in [Status](#status).
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.
1293
144
 
1294
- What lands next starts as a quirk sighting or a real-world edge case on the [issue tracker](https://github.com/cosyte/hl7/issues).
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.
1295
146
 
1296
- ### Out of scope (permanently)
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.
1297
148
 
1298
- - **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.
1299
- - **HL7 v3 and CDA**: different spec family entirely; not in our plans.
1300
- - **FHIR conversion**: [`@cosyte/transform`](https://github.com/cosyte/transform) converts a parsed message to FHIR R4; this library does not.
1301
- - **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.
1302
150
 
1303
151
  ---
1304
152
 
@@ -1306,20 +154,18 @@ What lands next starts as a quirk sighting or a real-world edge case on the [iss
1306
154
 
1307
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.
1308
156
 
1309
- Ask on the issue tracker, [github.com/cosyte/hl7/issues](https://github.com/cosyte/hl7/issues): questions, bug reports and quirk sightings all start there, and so does any refactor large enough to want discussion before a PR.
1310
-
1311
- 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.
1312
158
 
1313
159
  ---
1314
160
 
1315
161
  ## Trademarks
1316
162
 
1317
- 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
1318
- sponsored by any of them: the names identify the systems whose real-world message quirks the built-in profiles accommodate. See [TRADEMARKS.md](./TRADEMARKS.md).
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
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).
1319
165
 
1320
166
  ## License
1321
167
 
1322
- MIT, copyright Cosyte. See [LICENSE](./LICENSE).
168
+ MIT, Copyright (c) 2026 Cosyte LLC. See [LICENSE](https://github.com/cosyte/hl7/blob/main/LICENSE).
1323
169
 
1324
170
  ---
1325
171