Spec notes: batch / file envelope splitting
splitBatch(raw, optionsOrProfile?) demarcates the individual MSH-led
messages inside an HL7 v2 batch / file stream and hands each one back
already parsed (or, if it hit a fatal, as a typed failure entry). Real lab /
ELR / IIS feeds routinely ship many messages wrapped in the batch envelope,
and parseHL7 rejects non-MSH-first input outright (correct for a
single-message parser). So batch demarcation is a separate surface.
Why a splitter, not a parser option
The single-message parser's contract is "first segment is MSH". A batch file
starts with FHS or BHS. Folding batch handling into parseHL7 would either
weaken that invariant or silently swallow the envelope. Keeping splitBatch
separate preserves both contracts: splitBatch finds the message boundaries,
then delegates each message to parseHL7 unchanged (forwarding your profile /
strict / charset / dateFormats).
Spec traceability
HL7 v2 Chapter 2, the batch protocol:
- §2.10.3 (abstract syntax):
[FHS] { [BHS] { [MSH …] } [BTS] } [FTS]. Every envelope segment is optional; the braces repeat (a file holds many batches; a batch holds many messages).splitBatchmodels exactly this: an optional file header/trailer and theMSH-led messages between them. A batch is a run of messages delimited by aBHSheader and/or aBTStrailer: both are optional, so aBTScloses the preceding run into a batch even with noBHS. A run of messages with neither a header nor a trailer is not a batch: those messages are still yielded inmessagesbut belong to no entry inbatches(see "What is isolated" below). - §2.15, BTS (batch trailer): BTS-1 batch message count (optional,
[0..1]), BTS-2 comment, BTS-3 batch totals (repeating, NM). - §2.15, FTS (file trailer): FTS-1 file batch count.
- §2.10.3.3, acknowledging batches: three modes (ack-all / manual control report / ack-errored-only) on an exception basis. Batch ACK is out of scope for the splitter (see non-goals).
Real-world grounding:
- National CDC/APHL ELR makes batching optional: the Lab-to-EHR profile defines no envelope. A bare message must therefore pass straight through, and a batch with no counts must not be second-guessed.
- IIS file submission (e.g. Texas ImmTrac2 FTP) mandates the full
FHS/BHS…BTS/FTSframe and rejects a file on any missing header/trailer.splitBatchsurfaces a missing trailer (BATCH_MISSING_TRAILER) but does not reject: enforcing a profile's mandatory-envelope rule is the caller's decision.
Count reconciliation (fail-safe)
splitBatch reconciles the two well-defined declared counts against what it
actually split:
| Declared | Reconciled against | On mismatch |
|---|---|---|
| BTS-1 batch message count | messages in that batch | BATCH_COUNT_MISMATCH (unit message) |
| FTS-1 file batch count | batches in the file | BATCH_COUNT_MISMATCH (unit batch) |
The mismatch is surfaced, never repaired: every message is still returned.
The splitter never drops the tail to make the numbers agree. A count that is
absent (BTS-1 is [0..1]) or non-numeric tolerantly disables
reconciliation rather than fabricating a mismatch.
Both new warnings live on the returned BatchSplitResult.warnings (never
on Hl7Message.warnings) and carry only integers / segment names / positions.
No PHI: envelope headers carry facility/app identifiers, but the warnings
never echo a field value.
A few structural edges are handled so they neither drop data nor fabricate a false signal:
- FTS-1 reconciliation is per-file. A concatenated second
FHSresets the batch count, so two individually-conformant files do not raise a falseBATCH_COUNT_MISMATCH. - A lone
BTSclosing nothing (no messages, noBHS) is ignorable noise: it creates no phantom empty batch and no fabricated mismatch. Note the deliberate asymmetry: an emptyBHS-opened batch (a header with no messages) is a batch and counts toward FTS-1: aBHSexplicitly opens one, whereas a lone trailer opens nothing. - A trailing blank line immediately before a boundary is inter-message
whitespace, not part of the message (
parseHL7likewise strips a single trailing CR); interior empty segments are preserved, so each message'srawre-parses to exactly the segments the entry reports. - A missing trailer warns (
BATCH_MISSING_TRAILER) because content after a truncation point may be lost; a missing header does not warn: nothing is lost by aBTS/FTSthat closes an un-opened scope.
What is isolated, surfaced: never silently discarded
splitBatch never silently loses content: anything it cannot place in a
well-formed message comes back as a typed failure entry in messages.
- A malformed message mid-batch (one that trips a Tier-3 fatal: no
MSH, truncatedMSH, bad encoding characters, or, understrict, a promoted warning) is returned as{ ok: false, error, raw, position }. Its siblings before and after it still come back. This is the headline property: a mid-stream failure never suppresses a later message. - Stray content before the first
MSH, and the severed tail of a message that (malformedly) contained a reserved envelope name mid-body (see the reserved-name limitation below), is preserved as aNO_MSH_SEGMENTfailure entry: kept and surfaced, never discarded. - A message delimited by neither a
BHSnor aBTS(a bare stream, or content before the firstBHSthat is not closed by aBTS) is still yielded inmessages: it simply belongs to no entry inbatches(which holds each run delimited by aBHSand/or aBTS).messagesis the complete surface;batchesis the envelope structure over it. - A
Bufferstream is decoded/re-encoded vialatin1(a lossless 1:1 byte↔codepoint mapping) so each message's own MSH-18 charset resolution runs on its original bytes, not a stream-wide guess.
Known limitations / non-goals
splitBatch is a splitter, deliberately narrow. Two of these are structural
demarcation limits worth calling out explicitly:
- Reserved envelope names are matched by name (case-sensitive). A batch
stream is a flat
\r-delimited segment sequence; §2.10.3 disambiguates an envelope segment from a body segment by position in the nesting, which in a flat stream reduces to "a line namedFHS/BHS/BTS/FTSis a boundary." Those names are reserved (a conformant message body never contains one) so this is correct for well-formed input. But a (malformed) message whose body literally contains a segment namedFHS/BHS/BTS/FTSwill be mis-split at that line; the severed tail surfaces as aNO_MSH_SEGMENTfailure entry (never dropped). Matching is case-sensitive on purpose: a lenient lowercasebtsin a body is left in the message rather than mistaken for a boundary (a lowercasemsh-led "message" is not parseable anyway, so declining to treat it as a boundary loses nothing). - One file envelope per stream is the modelled case. §2.10.3's abstract
syntax is a single
[FHS] … [FTS]. Concatenated files (a secondFHS) are handled best-effort: batch demarcation, the flattenedmessages, and per-file FTS-1 reconciliation are all correct (a secondFHSresets the batch count, so two individually-conformant files do not raise a falseBATCH_COUNT_MISMATCH), but the single-valuedfileHeader/fileTrailer/declaredBatchCountresult fields reflect the first header / last trailer / last FTS-1. Inspectbatchesandwarningsfor the full multi-file picture. - No batch ACK generation. §2.10.3.3's three acknowledgement modes are not implemented; the splitter only demarcates and reconciles.
- No envelope enforcement. A profile may mandate the full frame (IIS); the
splitter warns (
BATCH_MISSING_TRAILER) and leaves accept/reject to the caller. - BTS-3 batch totals are surfaced, not reconciled. BTS-3 is an
application-defined repeating total (a checksum/sum whose meaning the sender
chooses). There is no generic "actual" to compare it to, so fabricating a
comparison would be a confidently-wrong answer. The raw BTS field tokens are
available on
batch.trailer.fields; computing/validating an application total is the consumer's job. - No typed FHS/BHS field helpers beyond the raw token array
(
envelopeSegment.fields). First-class file/batch-header accessors are a later candidate. - No transport de-framing. Splitting messages out of an MLLP byte stream is
@cosyte/mllp's job, not this one. - Multi-byte charset framing. A
Bufferstream is line-ending-normalized at the stream level (its\r/\nframing bytes are ASCII) before each message is re-decoded with its own MSH-18 charset. This is byte-exact for the single-byte / ASCII-superset charsets HL7 batch framing requires (UTF-8, ISO-8859-x, Shift-JIS), but not for a multi-byte charset (e.g. UTF-16) whose payload embeds raw0x0A/0x0Dbytes: such batch files effectively do not exist, but the framing assumption is worth stating.
MSH-family field indexing (a gotcha)
BatchEnvelopeSegment.fields is the segment split on its own field separator,
with fields[0] holding the segment name. Mind the HL7 MSH-family quirk:
FHS/BHSmirrorMSH: field 1 is the field separator itself, sofields[1]is the encoding-characters field (FHS-2/BHS-2).BTS/FTSare ordinary segments:fields[1]is field 1 (BTS-1 batch message count / FTS-1 file batch count).
declaredMessageCount (from BTS-1) and declaredBatchCount (from FTS-1) are
parsed for you, so most callers never touch the raw fields at all.