A portable HL7 v2 transformation bench. No installer, no admin rights, no
service, no container. One bun.exe and four files.
Built for the workstation problem every hospital interface engineer knows: the machine where you actually do the work is the machine where you are not allowed to install anything. This runs out of a user folder.
stdin <- raw HL7 stdout -> transformed HL7
stderr -> diagnostics exit 0 = success
That is the whole interface. Anything that can spawn a process can drive it.
flowchart LR
SPEC[("Spec — the mapping, as data")]
MSG[("Synthetic HL7 — DFT · ADT · ORU")]
SPEC --> BENCH
MSG --> BENCH
subgraph BENCH[" hl7-bench · one bun process, stdin to stdout "]
direction TB
T["transform<br/><b>see every changed field</b>"]
E["emit<br/><b>ObjectScript · JS</b>"]
KNOB{{"style<br/>lean · accumulate"}}
KNOB -. shapes .-> E
end
BENCH --> CHECK{"run on real IRIS<br/>byte-compare golden"}
CHECK -- "differs" --> SPEC
CHECK == "identical" ==> OUT(["Verified class<br/><b>paste into Studio</b>"])
classDef io fill:#0f2a3a,stroke:#3f8fc0,color:#e8f4fb,stroke-width:2px
classDef work fill:#123024,stroke:#3fa06a,color:#d5f0e2,stroke-width:2px
classDef knob fill:#2a2313,stroke:#c9a227,color:#f2e6b8,stroke-width:2px
classDef gate fill:#2a1330,stroke:#a05fc0,color:#efd9f5,stroke-width:2px
classDef win fill:#123024,stroke:#5fd08a,color:#eafff2,stroke-width:3px
class SPEC,MSG io
class T,E work
class KNOB knob
class CHECK gate
class OUT win
style BENCH fill:#0d1117,stroke:#3fa06a,color:#d5f0e2
linkStyle 2 stroke:#c9a227,stroke-width:1.5px
linkStyle 4 stroke:#c0526a,stroke-width:2px
linkStyle 5 stroke:#5fd08a,stroke-width:3px
Is: a real transform loop. Write the interface as a spec, run it, see the
transformed message with every changed field highlighted, and generate the
engine's own transformation language from the same spec. The escape hatch is
still there: transform() receives the parsed message and may mutate it
directly, which is the contract Mirth taught you.
Is not: an interface engine. There is no channel, no queue, no listener, no persistence. It transforms one message and exits. If you need routing and delivery, you need an engine; this is the bench you use before you get to one, or when the engine you need cannot be installed where you are sitting.
It is also not a substitute for a vendor's own transformation language. If you are learning InterSystems DTL, DTL only executes inside IRIS, and IRIS is a server install. Learn the language on a machine that can run it. Use this where that machine is not available.
bun gui.tsOpens http://127.0.0.1:7317. Message on the left with stderr under it, the
spec in the middle as a form, and on the right the transformed message with
changed fields highlighted, the generated ObjectScript, the mapping document,
the source inventory, and the transform.ts that is about to be written.
The middle column is the spec, not code. There is no JavaScript pane, because
the artifact you are after is ObjectScript and typing one language to produce
another is the thing this design removes. Every kind in spec.ts has a form,
and the right column recomputes as you type without touching disk, so the
ObjectScript tab answers "what does this become in IRIS" while you are still
deciding what the interface does.
Copy segments to spec, on the Message in header, reads the message in front of
you and lists the segments it found. Tick some, press Add to spec, and each
becomes a block with one copy() row per field that is populated in any
occurrence of it. A segment that appears more than once gets a repeat. Rows
you already have are left alone, so a second run adds only what the message
grew. It is a first draft: every row is a straight copy, and the lookups, the
control id and the deletions are still yours.
Ctrl+Enter validates, rewrites the spec literal in transform.ts, and runs
it. A spec that does not validate is not written. Everything above and below
the literal survives byte for byte, comments inside it do not, and the first
write of a session leaves a transform.ts.bak.
draft every in the header is autosave, and it is deliberately not a save. On
the interval you pick, off / 1 / 5 / 15 minutes, the browser posts the spec to
logs/autosave/transform.draft.json and nothing else happens: transform.ts is
not touched, the .bak is not spent, and no bench.ts run is triggered. If the
laptop dies with unsaved work, the next load offers it back. A real save clears
it. The message pane is never in that file, because a timer copying a live
message to disk every few minutes is an exposure nobody asked for. Your choice
of interval is remembered per browser.
copy, on the right of the tab bar, copies the visible tab. Ctrl+C does the
same when the browser would otherwise copy nothing: a real selection still wins,
and so does a focused input, so Ctrl+C inside a spec field behaves as it always
did. The ObjectScript tab is the one this is for. That class exists to be pasted
into Studio, and a drag-selection that stops one line short of the closing brace
produces a class that will not compile for a reason nothing on screen explains.
Both message panes tell you where you are. Put the caret anywhere in the
message on the left and the pane heading reads back the position: PV1-7[2].1 · PV1 #1 of 1 · line 5. Hover a field on the right and the same label
appears in a tooltip. Repetition and component are shown only when the field
actually has them, so a path that is all ones stays short. The MSH line is
handled the way HL7 defines it rather than the way it splits: MSH-1 is the
field separator and MSH-2 is the encoding characters, so everything after
the segment id sits one slot left of a naive pipe count, and MSH-2 is never
broken into components because it is the field that defines what a component
separator even is. Counting pipes by eye is how a mapping ends up one field off.
Bound to loopback deliberately. Binding a public interface is what triggers the Windows Firewall prompt you cannot approve without admin, and this endpoint writes a file and executes it. It has no business listening anywhere else.
Notepad++ (with PipeHat)
This repo ships hl7-bench.provider, so installing is a copy:
<Notepad++>\plugins\config\providers\hl7-bench\
Drop bun.exe in the same folder and restart Notepad++. Nothing to edit. The
provider file uses ${DIR} for its own location, so it contains no username and
works wherever you unzip it. Uninstall by deleting the folder.
Ctrl+Alt+Shift+X picks the provider, Ctrl+Alt+Shift+A re-runs it. The result
lands in the other view and PipeHat diffs it field by field.
If you would rather declare it by hand, PipeHat.providers still works and
takes precedence over any drop-in that reuses the same name.
Get-Content message.hl7 -Raw | bun bench.ts- Download
bun-windows-x64.zipfrom bun releases. - Extract
bun.exeanywhere writable.C:\Users\<you>\tools\is fine. - Clone or unzip this repo next to it.
bun test, 422 tests, no network, no dependencies.
Nothing is installed. Nothing touches the registry. Notepad++ has an official portable build too, so editor, plugin, and bench all fit on a stick.
If your site blocks unsigned executables from user-writable folders, this approach will not work and no amount of arguing with the policy will change that. The same bench logic ports to PowerShell if you need a zero-download option.
import type { Message } from "./hl7";
export function transform(msg: Message): void {
msg.set("MSH-3", "BENCH");
msg.set("PID-5.1", msg.get("PID-5.1").toUpperCase());
msg.set("PID-8", msg.get("PID-8") === "M" ? "MALE" : "OTHER");
for (const obx of msg.all("OBX")) {
obx.set("OBX-15.1", "LAB");
}
}Paths are SEG-F, SEG-F.C, SEG-F.C.S, with an optional repetition index.
PID-3(2).5 is the identifier type of the second repetition of PID-3.
msg.seg(id) gets the first occurrence, msg.all(id) gets every one.
Throwing aborts the run and puts the error, with the line number in your file, in front of you:
transform() threw:
Error: No ZZZ segment in this message
at transform (transform.ts:3:7)
That is the feedback loop. Break things on purpose while it is cheap.
Writing the transform as code works, and for a one-off it is the shortest path. It stops working the moment the same interface has to exist in three places at once: running on the bench, written down for the receiving team, and built in the engine. Three copies, hand-synced, with nothing checking that they agree.
So the interface is declared as data, and everything else is derived from it.
export const spec: Spec = {
name: "Registration ADT to downstream",
gate: {
path: "MSH-9.2",
permit: { A01: "A28", A08: "A31" },
require: [{ path: "MSH-9.1", equals: "ADT" }], // or { path, inTable: "Table" }
},
iris: {
className: "Demo.DTL.AdtToDownstream",
sourceDocType: "2.3:ADT_A01",
targetDocType: "2.3.1:ADT_A05",
},
tables: { Department: { "S45": "2^TEST DEPT" } },
blocks: [
{
id: "PID",
rows: [
{ target: "PID-3", from: firstOf("PID-4", "PID-3"), label: "MRN", required: true },
{ target: "PID-5", from: copy("PID-5"), label: "Patient Name", required: true },
{ target: "PID-19", from: copy("PID-19"), via: [stripChars("- ")], label: "SSN" },
],
},
{
id: "IN1",
group: "INSURANCEgrp",
repeat: { over: "IN1", skipWhenEmpty: "IN1-4", max: 3 },
rows: [
{ target: "IN1-1", from: counter() },
{ target: "IN1-4", from: copy("IN1-4"), label: "Insurer", required: true },
{ target: "IN1-22", from: counter(), label: "Coverage Priority" },
],
},
],
};That one object drives everything else:
bun bench.ts |
runs it, and the message comes out |
bun check.ts |
proves it against golden files |
bun gate.ts |
PERMIT or REFUSE per message, every gate clause, near misses on a table |
bun trace.ts |
the field table you hand the receiving team |
bun reads.ts |
every source path that came back with nothing |
bun emit.ts |
the IRIS DTL class, ObjectScript and all |
bun emit.ts process |
a business process template that calls it |
bun emit.ts tables |
the lookup table rows as an IRIS import file |
bun emit.ts promote |
the per-namespace promotion checklist, as Markdown |
Change a row and all of them change together, or none of them do. There is no second copy to keep in step.
A rule could just as easily be a function. It is a tagged union instead, so the spec stays serializable: the GUI can edit it as a form rather than as code, and a backend for a second engine is a pure function over the same object.
There is deliberately no raw(javascript) escape hatch inside a spec. The
moment one exists, the JavaScript side can express things the ObjectScript side
cannot, which is precisely the split this design closes. When the vocabulary is
short of something, the vocabulary grows.
spec.test.ts walks SOURCE_KINDS and STEP_KINDS and asserts that every
backend handles every kind. Add a source kind, teach it to the runner only,
and the build fails naming the backend you forgot. A vocabulary that one backend
understands and another silently ignores is the failure this design exists to
prevent, so it is a compile-time failure rather than a production one.
| Sources | copy, literal, firstOf, lookup, counter, event, pickRepeat, fromFirst, todo |
| Steps | date8, truncate, upper, stripDelims, stripChars, defaultTo |
| Unmapped branches | blank(), passthrough(), constant(v), { error: true } |
| Block controls | group, repeat.over, repeat.skipWhenEmpty, repeat.skipValues, repeat.max, bundle |
Every one of these is silent in a hand-written transform, and each one has cost somebody a go-live:
- An empty lookup table returns the unmapped branch for every message. It looks exactly like a working lookup. Both backends flag it on every run.
- Assigning an empty value creates the field. That is what
<assign>does in a DTL, so the bench does it too. Skipping empty assigns produces a shorter segment than the engine, and a receiver reading by ordinal position sees a different message than the one that was signed off. - Set ids number by output ordinal, not source repeat index. A skipped
first coverage would otherwise deliver a lone
IN1numbered 2, with priority 2 and no priority 1. firstOfsays which path it actually used. "We map PID-4" and "PID-4 is empty at this site" are different statements.- Skipped and capped repetitions are counted. Nothing else on the wire shows that three coverages arrived and one was dropped.
||binds looser than&&, so the emitted routing condition parenthesises the trigger group. Unparenthesised, one requirement term lets everything else through.
bun emit.ts -o MyTransform.cls # diagnostics go to stderr, so the file stays clean
bun emit.ts iris # same thing, named explicitly
bun emit.ts process -o MyProcess.cls # a business process that calls the DTL
bun emit.ts tables -o Tables.xml # the lookup rows, as an IRIS import file
bun trace.ts msg.hl7 -o mapping.txt # the mapping document, as a file to sendThe emitter cannot check four things for you, and says so in the class header: the DocTypes against your own schema browser, whether a target segment sits inside a group, whether every lookup table has rows, and the routing rule.
A wrong DocType fails closed: paths stop resolving, the output comes out
empty, and nothing useful reaches the log. Same for a grouped segment addressed
without its group. target.{IN1(1):2} resolves to nothing where
target.{INSURANCEgrp(1).IN1:2} works, with the same silence.
Both emitted classes carry the same twelve-character hash of the spec:
/// Spec fingerprint: 9a62d97231a1
bun emit.ts prints it to stderr as well. If the string in the class you are
reading in the portal does not match the one the bench prints, the namespace is
running an older compile than the mapping you are looking at, and every minute
spent debugging the mapping is wasted. That is the whole point of it. It covers
the spec, including labels and notes, so a cosmetic edit moves it too. It does
not cover the class file: hand-edit the generated .cls and the fingerprint
becomes a lie.
The DTL header says proven on the bench and has earned it: real messages went
through that mapping and you read the output. bun emit.ts process has executed
nothing, because there is no production on a laptop, so it says TEMPLATE in its
first line instead.
It emits one shop's shape: a custom Ens.BusinessProcess that clones the
request, calls the DTL, and dispatches by config item name with
SendRequestAsync. Plenty of sites use a routing engine with a rule whose
transform field names the DTL and never write a process class at all. If that is
you, read it and take the parts you need.
iris.process.sendTo is the config item name as the production spells it, and
it is the one fact in the whole spec that cannot be derived from the mapping. A
name that does not resolve fails at run time, per message, not at compile time.
Note the gate then exists in two places: the routing rule condition the DTL header prints, and the filter in the process. That is deliberate. A gate that exists in neither is the failure that matters, and it is silent.
Most fixed values belong in the mapping. A field that carries one value for every message this interface sends is a block row and nothing else:
{ target: "MSH-4", from: literal("WEST_LAB") }That way the delivered trace shows it, the fingerprint covers it, and there is one place to look.
A stamp is for the value the transform cannot know, because it depends on
where the message is going rather than on what it contains. One DTL feeding two
receivers that each want their own sending facility is two processes, two
sendTo values and two stamps over one shared transform. Expressing that in the
DTL means forking the DTL.
process: {
className: "Site.Interface.Process.AdtToRegistration",
sendTo: "ToRegistration.ADT.TCP",
stamp: [
{ path: "MSH-4", value: "WEST_LAB", why: "this receiver keys routing on sending facility" },
],
},why is required, for the same reason lookup makes you name its unmapped
branch. A stamp with no stated reason is indistinguishable from a leftover
nobody was willing to delete.
What it emits, after the transform and before the dispatch:
set tTarget.IsMutable = 1
// this receiver keys routing on sending facility
do tTarget.SetValueAt("WEST_LAB", "MSH:4")IsMutable is load bearing and is why this is a generator feature rather than
two lines you type. A message that has been through a DTL or has been saved
refuses SetValueAt, and it refuses at run time, per message, with
<Ens>ErrGeneral: Cannot modify immutable message. The class compiles without
that line.
bun check.ts refuses two stamps on one path, a stamp with an empty why, and
a stamp on a path a block row already assigns. The last one is the expensive
one: the stamp runs after the transform and wins, so the trace document you hand
the receiver describes a value they never see.
The stamps are also listed in the class header, with that warning attached, because the delivered trace does not account for them.
Lookup tables are namespace data, not code. They do not travel with a class export and they do not travel with a production deployment, which is how a transform arrives in production correct and translates nothing.
bun emit.ts tables -o Tables.xml # every table in the spec
bun emit.ts tables --table Facilities # just the oneImport it in the portal at Interoperability > Configure > Data Lookup
Tables, or with ##class(Ens.Util.LookupTable).%Import("Tables.xml"). Verify
the document shape once against your own version by exporting an existing table
and diffing.
An empty key is refused. A control character anywhere is refused, because the
import error names a line number in the file rather than the row that caused it.
An empty value is warned about and still written: Lookup returns your
default for a blank value exactly as it does for a missing key, so the row
behaves as if it is not there, which in an allowlist is a permitted code being
silently refused.
Facility tables, department tables, provider crosswalks. The ones that arrive as four hundred rows in Excel and are otherwise retyped by hand.
Get-Content facilities.csv -Raw | bun tables.ts Facilities
bun tables.ts Facilities --into-spec facilities.csv # writes it into the active spec
bun tables.ts Facilities --module facilities.csv > tables.facilities.ts
bun tables.ts Sex --key 2 --value 3 --delim tab codes.txtIt writes TypeScript, not XML, on purpose. Straight to XML is one step shorter
and puts the rows somewhere the bench cannot read, so lookup() on the bench
would translate nothing while the same interface in IRIS translated fine.
spec.tables stays the single source and bun emit.ts tables makes the import
file from it.
Keys and values are trimmed and the count is reported. A trailing space in a
key is invisible in every editor and in the portal, survives a copy-paste, and
makes Lookup miss. It is the commonest defect in a hand-built table. --no-trim
if your keys really do carry spaces.
The same key twice with the same value is collapsed, and all such repeats are counted in one warning line. The same key twice with different values is a refusal: that is two people who disagree about what a code means, and picking one silently is how the wrong one reaches production.
A one-column file is an allowlist: each code maps to itself, and stderr says so.
--value-literal 1 stores a fixed value instead. A one-column file whose cells
hold a tab, semicolon or pipe is the wrong --delim, not an allowlist, and the
tool names the delimiter it found.
Get-Content real.hl7 -Raw | bun reads.ts
bun reads.ts --strict real.hl7 # exit 1 if a block is at riskThe emitted class sets IGNOREMISSINGSOURCE = 1, which turns a source path IRIS
cannot resolve into a skipped <assign> rather than an error. Skip every
assign in a block and the target segment is never created, so a whole section of
the mapping disappears with no error, no warning, and nothing in the Visual
Trace to look at. The delivered message is shorter, parses cleanly, and looks
fine. Two days on a live build, four seconds here.
Two headlines, and they are not the same problem:
| AT RISK | every path the block reads belongs to a segment that is not in this message. This is the shape that creates no segment at all |
| DELIVERS EMPTY | the paths resolved and every row came back blank. IRIS still creates the segment, and the receiver gets an empty one |
It cannot see groups. The bench message model is flat, so a block with the wrong
group name reads perfectly here and writes nowhere in IRIS. That is the exact
failure this report is named after and the one case it will tell you is fine.
Read the group name off your schema browser.
emit/iris.ts is the only backend shipped, because IRIS is the engine in front
of us. emit.ts dispatches on a name and the completeness test already knows
how to fail a half-finished backend, so adding one is a new file and a new row
in a table, not a rewrite.
MSH-1 is the field separator and MSH-2 is the encoding characters, so
on the MSH line every field sits one slot left of where a naive split puts it.
Get this wrong and you silently shift message type, control ID, and version,
and it reads fine right up until a receiver rejects everything.
hl7.ts handles it and hl7.test.ts locks it down. MSH-2 is additionally
never split by the delimiters, because it is the field that defines them;
splitting it returns ^ instead of ^~\&. The tests caught that one on the
first run of this repo.
Delimiters are read from each message rather than assumed, so a feed that
declares ! as its field separator parses correctly.
The spec above targets HL7 out. toolbox.ts targets everything else: a CSV, a
billing record, a REST payload, anything where the destination is a flat list of
named fields rather than segments. Same idea, different shape, and it does the
one thing the spec deliberately does not, which is turn one message into N
records.
Use spec.ts when the output is HL7 and an engine has to run it. Use this when
you are pulling a flat extract out of a message, or reverse-engineering a feed
before you know what the interface is yet.
import { Message } from "./hl7";
import { mapEach, renderTrace, date8, upper, signed, join, type Rule } from "./toolbox";
const rules: Rule[] = [
{ to: "Facility", from: "MSH-6.1", required: true },
{ to: "AccountNumber", from: "PID-3.1", required: true },
{ to: "LastName", from: "PID-5.1", via: [upper()], required: true },
{ to: "FirstName", from: join(" ", "PID-5.2", "PID-5.3") },
{ to: "ServiceDate", from: "FT1-4", via: [date8()], required: true },
{ to: "ChargeCode", from: "FT1-7.1", required: true },
{ to: "Quantity", from: "FT1-10", via: [signed("FT1-6", ["CH"], ["CG","R"])] },
];
console.log(renderTrace(mapEach(msg, "FT1", rules)));--- record 1 of 2 ---
TARGET SOURCE RAW STEPS FINAL
--------------- --------------------------- -------------- --------------------------- -----------
Facility * MSH-6.1 RECVFAC RECVFAC
AccountNumber * PID-3.1 MRN9 MRN9
LastName * PID-5.1 doe uppercase DOE
FirstName join(" ", PID-5.2, PID-5.3) john q john q
ServiceDate * FT1-4 20260811093000 date8 (YYYYMMDD) 20260811
ChargeCode * LAB001 LAB001 LAB001
Quantity FT1-10 2 signed by FT1-6 (+CH -CG/R) 2
Rules are data, so they print. That table is the artifact you hand an analyst.
mapEach(msg, "FT1", rules) maps once per repeating segment. A DFT with four
FT1 segments is four charges; an ORU with twenty OBX is twenty results. If your
target is one flat record, one message legitimately becomes N records, and
a mapping written against the first segment drops the rest without a word.
The record count in the output tells you immediately whether you are looking at a 1:1 or a 1:N interface. That question is cheap to ask here and expensive to discover in production.
Signing a quantity off a transaction type is usually written:
qty = type === "CH" ? q : "-" + q; // don'tEvery type that is not CH now becomes a credit: blank, unknown, a new code the
vendor shipped last month, a typo at the sender. Flip the branches and unknown
types silently become charges instead. Two feeds into one billing system with
opposite defaults is not hypothetical.
signed(typePath, positive[], negative[]) takes both lists explicitly and
throws on anything in neither. If you want a default, say so with defaultTo
before it, where the next reader can see it. The same reasoning is why lookup
makes you pass "passthrough" | "blank" | { error: true } rather than picking
one for you.
| Runners | mapOne, mapEach |
| Getters | literal, join, coalesce, compute |
| Steps | date8, truncate, upper, stripDelims, defaultTo, lookup, lookupChain, signed |
| Output | renderTrace, toPipeDelimited |
required: true collects every missing field, not the first one. An
operator who has to resubmit a record once per missing field will stop telling
you about them.
There are two different logs here and they answer two different questions. One is this bench writing files on your machine. The other is the generated class writing to the IRIS Event Log on a server. They share nothing.
Off by default, and that is deliberate. The contract is stdin in, stdout out, stderr for diagnostics, nothing installed and nothing left behind. A tool that starts dropping files because you ran it breaks that quietly for anyone piping it out of PipeHat or a script.
HL7_BENCH_LOG |
|
|---|---|
| unset | nothing is written |
summary |
one line per event in logs/hl7-bench.log: timestamp, tool, spec, gate decision, fields written, missing count, how many notes, result |
full |
the same line, plus every diagnostic note verbatim |
HL7_BENCH_LOG_FILE=<path> moves the bench log somewhere else. run.ts,
bench.ts, check.ts, emit.ts and trace.ts all write to it.
navcheck.ts and schema-sync.ts are the only things here that talk to an
engine. They default to a Docker container, because that is where they were
written.
| Variable | Default | What it is |
|---|---|---|
IRIS_MODE |
docker |
local for a native install |
IRIS_EXE |
iris |
the binary, when it is not on PATH. Windows: <install dir>\bin\iris.exe |
IRIS_INSTANCE |
IRIS |
as iris list spells it |
IRIS_NAMESPACE |
USER |
must be interoperability-enabled, or EnsLib.HL7 is not there |
IRIS_LAB_DIR |
/lab |
where the ENGINE reads a message from, which is not where you type the filename |
IRIS_CONTAINER |
iris-lab |
docker mode only |
IRIS_USER |
unset | only for an instance whose console prompts |
IRIS_PASSWORD |
unset | the password, in the clear |
IRIS_PASSWORD_CMD |
unset | a command whose first line of stdout IS the password. Wins over IRIS_PASSWORD |
The credentials pair, and why it exists. An instance with password
authentication on its console service answers a piped script with Username:,
reads the first line of ObjectScript as the username, and exits having written
nothing to stderr. A piped session can answer those prompts, so setting the pair
sends them as the first two lines and nothing about the instance's security has to
change. Leave both unset for an instance that does not prompt -- a username sent
to an instance that is not asking goes to the ObjectScript interpreter instead.
They belong in .env, which is gitignored. Not on a command line, where shell
history keeps them.
Keeping the password out of the file entirely. IRIS_PASSWORD_CMD runs a
command and takes its first line, so .env holds a command rather than a secret:
IRIS_PASSWORD_CMD=powershell -NoProfile -ExecutionPolicy Bypass -File .\Tools\Get-IrisPassword.ps1
Tools\Set-IrisPassword.ps1 writes the password DPAPI-protected, once. DPAPI
encrypts with the logged-in Windows account's own key, so the file decrypts only
for that user on that machine -- copy it elsewhere and it is ciphertext. A
credential manager, a vault CLI or gpg -d fit the same hook.
What does not work, however it is dressed up: encrypting the password and keeping the key beside it. The tools decrypt unattended, so anything they can read, a reader of the folder can read. That is obfuscation. It is worth doing only when the key lives somewhere the file does not, which is exactly what DPAPI provides and a keyfile in the same folder does not.
A resolved password is scrubbed out of any transcript these tools print, so a failure report cannot quote it back.
HL7_BENCH_TRANSFORM=transform.site.local.ts
Unset, the spec is transform.ts here and nothing changes. Set, every reader
follows it: bench.ts, check.ts, emit.ts, navcheck.ts, schema-sync.ts,
trace.ts, reads.ts, and gui.ts, which also saves there.
Two failures it removes. On a machine without git the tool upgrades by unpacking
a zip over the folder, and transform.ts is the one file in there that cannot be
replaced from upstream, so the upgrade either destroys the interface or never
happens. And this repo is public: a tracked transform.ts holding a real mapping
carries a customer's name, their vendor, accession numbers and the reasoning
behind every row.
Keep the file in this folder and name it *.local.ts, which is gitignored.
Both failures are still removed: a zip unpacked over the folder carries
transform.ts and not your file, and a gitignored file cannot be pushed.
Not a sibling folder, which is the obvious first guess and does not work. A spec
imports the vocabulary relatively -- ./spec, ./run, ./hl7 -- and those
resolve against the SPEC FILE, so a file one directory over fails with
Cannot find module './run', which reads like a broken bench install. A truly
external path works only where its imports resolve. specfile.ts detects that
case and says so rather than leaving you with the raw message.
The file is an ordinary spec module -- it exports spec, and may export
transform. A copy of transform.ts already qualifies, so moving an interface out
is a rename and a variable, not a rewrite.
It fails closed. A path that is not there, or a module with no spec export,
stops the run rather than quietly delivering the demo mapping, which exits 0 and
produces something that looks like work.
The split is about PHI, not about verbosity. Most notes are paths and
counts. Two are not. An unmapped lookup names the source value that missed the
table, and a gate refusal on a require rule names whatever that rule read.
Nothing stops a table or a rule being keyed on PID-3, and then the log has
MRNs in it. summary never writes note text. full is a choice you make.
logs/ is gitignored either way, which is a seatbelt and not permission.
The GUI keeps its own log, logs/authoring.log, and that one is on by
default, because Ctrl+Enter already rewrites transform.ts and leaves a
.bak. It records what happened, not what you wrote: spec name, whether it
validated, blocks and rows before and after, saved or not, and the bench exit
code. The spec content itself is what git is for. HL7_BENCH_LOG=off silences
it along with everything else.
An autosave tick lands there too, as action=draft saved=draft-only, which is
how you tell a draft from a save when reading the file back.
That is a property of the interface, so it lives in the spec:
iris: {
sourceDocType: "2.3:ADT_A01",
targetDocType: "2.3.1:ADT_A05",
log: "warn", // "off" | "warn" | "trace", default "warn"
},warn emits $$LOGWARNING at exactly two places: a lookup code with no row in
its table, and a required target that came out empty after the assign. Both
are the questions you actually get asked at 2am. trace adds $$TRACE per
assigned field, which shows up in Visual Trace when tracing is on for that host.
off emits neither, and skips Include Ensemble entirely.
Gate refusals are not in there on purpose. The gate is a routing rule, so a
refused message never reaches the transform at all. bun emit.ts prints the
rule condition above the class.
One thing to know before you leave it on warn: a sender that routinely emits
an unmapped code writes one Event Log warning per message until the table is
fixed. That is usually what you want, right up until it is not.
| File | |
|---|---|
transform.ts |
the file you edit. Holds the spec |
spec.ts |
the vocabulary: source kinds, step kinds, validate |
run.ts |
the runner. Walks the spec and produces the message |
trace.ts |
the same walk, rendered as the mapping document |
emit.ts |
picks a backend and an artifact, and writes it |
emit/iris.ts |
the IRIS DTL backend |
emit/process.ts |
the business process template backend |
emit/lookup.ts |
spec.tables as an IRIS lookup table import file |
tables.ts |
a spreadsheet becomes a spec.tables entry |
reads.ts |
every source path that resolved to nothing |
fingerprint.ts |
the spec hash both emitted classes carry |
hl7.ts |
parse, serialize, path lookup |
bench.ts |
the stdin/stdout wrapper |
check.ts |
the golden gate |
gate.ts |
would this message get through: the gate, clause by clause |
serialize.ts |
prints a spec back into transform.ts, so the GUI can save |
gui.ts + gui.html |
the local browser spec editor |
toolbox.ts |
flat-record extraction and its field trace |
log.ts |
the log switch. Off unless HL7_BENCH_LOG says otherwise |
specpath.ts |
which file holds the spec. Resolves HL7_BENCH_TRANSFORM, no side effects |
specfile.ts |
loads that file, and fails closed if it is missing or is not a spec module |
hooks/pre-push |
refuses a push that would publish a real interface. git config core.hooksPath hooks |
*.test.ts + emit/*.test.ts |
440 tests across 13 files |
sample.hl7 |
synthetic ADT^A01 |
classify.ts |
diff what you have against what you want |
patterns.ts |
twelve moves, each with its IRIS DTL |
CHEATSHEET.md |
which command, and when. The one to keep open on a call |
WORKFLOW.md |
start here. New interface to IRIS, nine steps |
METHOD.md |
the five questions, path syntax, the traps |
sample.hl7 is synthetic. .gitignore excludes every other .hl7 and a
messages/ directory, because committed PHI is not recoverable after the fact.
Scrub before you share. PipeHat has a
fail-closed scrubber if you need one.
MIT.