Skip to content

Repository files navigation

iris-lab

A local InterSystems IRIS for Health sandbox plus a one-hotkey round trip from Notepad++: edit a transform, hit the key, see the transformed message.

Built for learning IRIS interoperability coming from a Mirth/BridgeLink background.


Why this shape

The obvious reading of "a Notepad++ plugin that runs IRIS transforms" is a new plugin. It should not be. Two reasons:

  1. PipeHat already is the plugin. It owns HL7 loading, parsing, the message tree, and field-aware Compare Views. A second plugin would rebuild all of it to add one button.
  2. The hard part is not the editor, it is the execution engine. DTL only runs inside IRIS. Anything you build in Notepad++ is a client to an IRIS instance you still have to stand up. Stand up the instance first; the editor integration is then a four-line NppExec script.

So v1 is: Docker sandbox + a bun driver + NppExec. v2 folds the driver into PipeHat as a command so the result lands in the second view and Compare Views highlights every changed field automatically.


Setup

# Start Docker Desktop first, then:
cd C:\opencode\iris-lab
docker compose up -d          # ~1 GB pull the first time, a few minutes
bun setup.ts                  # one-time: enable interoperability on USER
bun xform.ts                  # the round trip

bun setup.ts must print ENSEMBLE-ENABLED=1 and HL7-CLASS=1. If it does not, nothing downstream will compile, because EnsLib.HL7.* is only mapped into interoperability-enabled namespaces.

Management Portal: http://localhost:52773/csp/sys/UtilHome.csp (_SYSTEM / SYS).

License: Community Edition is development-and-learning only. Fine for this. Not fine for anything client-facing.

Against a native install instead of the container

.\lab.ps1                                    # transform lab\input.hl7
.\lab.ps1 -In messages\meditech-a01.in.hl7   # transform that file instead

It asks for a password the first time in a shell and remembers it for that shell only. Nothing else to set: the instance, the namespace, and the path to irissession.exe are all discovered or defaulted. Add -Instance only if you have more than one install and it picks the wrong one.

The environment variables underneath, if you would rather drive xform.ts directly:

$env:IRIS_MODE     = "local"
$env:IRIS_INSTANCE = "<from `iris list`>"
$env:IRIS_NAMESPACE= "USER"
Get-Content lab\input.hl7 -Raw | bun xform.ts

Three things differ from the container, all of them discovered the hard way:

There is no iris session on Windows. That spelling is UNIX-only; iris.exe rejects session as an invalid parameter. The scriptable terminal is a separate executable, irissession.exe, in the instance's own bin directory. xform.ts looks for it in three places, in order: $env:IRIS_SESSION_EXE, the directory: line of iris list, then a scan of the standard install roots. That last one exists because a stock Windows install puts iris.exe in the instance's own bin directory and never adds it to PATH — so on a perfectly healthy machine, iris list cannot run at all and used to fail with "Is IRIS installed and on PATH?", which was both wrong and pointed at the wrong variable.

Keep every piped line short. The terminal wraps a long input line, and a wrapped line is two ObjectScript commands rather than one. Each half is a <SYNTAX> error, and because the break lands mid-string-literal neither error names the real problem. xform.ts assigns paths to r/i/o/x first and keeps the call that uses them under 60 characters for exactly this reason. It never came up in docker, where /lab/input.hl7 is 14 characters.

Pipe LF, not CRLF. A PowerShell here-string ends its lines \r\n, the trailing \r rides along into the password, and you get Access Denied on a password that is visibly correct. Same for the UTF-8 BOM PowerShell 5.1 writes ahead of the first byte on a pipe to a native executable — that one shows up as Username: _system. xform.ts sidesteps both by writing bytes through Bun.spawnSync instead of going through a shell.

A native install asks for a password. The container has no web application and makes no auth decision, so this never comes up there. A piped session gets Access Denied on the first line unless it supplies credentials:

$env:IRIS_USER     = "<your iris username>"
$env:IRIS_PASSWORD = Read-Host "IRIS password"

Read-Host keeps it out of your shell history. Credentials go over stdin, not command-line arguments — arguments are visible to every other user on the box through the process list, which on a shared or work machine is not theoretical. Don't persist them into a script or a machine-level environment variable.

Windows PowerShell 5.1 will show a red NativeCommandError on success. Not a failure. xform.ts follows the UNIX split — the transformed message goes to stdout so it can be piped, and the OK Lab.Transform ... ms status goes to stderr so it never contaminates the message. PowerShell 5.1 wraps any stderr output from a native executable in an ErrorRecord and renders it red, regardless of exit code.

Check the thing that actually matters:

$LASTEXITCODE     # 0 means it worked

To silence the noise, at the cost of losing the timing line:

Get-Content lab\input.hl7 -Raw | bun xform.ts 2>$null

PowerShell 7 does not do this. Neither does cmd.

The namespace needs interoperability enabled. Check it once:

zn "USER"
write ##class(%EnsembleMgr).IsEnsembleNamespace($namespace)
write ##class(%Dictionary.CompiledClass).%ExistsId("EnsLib.HL7.Message")

Both must print 1. If the first is 0: do ##class(%EnsembleMgr).EnableNamespace($namespace,1) — but only on an instance that is yours. It modifies the namespace, and change control on a shared dev engine belongs to someone else.


The loop

File What it is
lab/input.hl7 The sample message. Paste any HL7 v2 here.
lab/Transform.cls The file you edit. Your DTL. Must stay named Lab.Transform.
lab/output.hl7 The result. Overwritten every run.
src/Lab.Runner.cls The harness. Recompiles your transform on every run.

Errors land in lab/output.hl7 prefixed with ###, on purpose — you should see the compile error in the editor, not go digging through container logs.

The contract

xform.ts is a UNIX filter and nothing more:

stdin   <- raw HL7
stdout  -> transformed HL7
stderr  -> diagnostics (compile errors, timings)
exit 0  =  success, non-zero = failure

Nothing in it knows about Notepad++. Run it from a shell, a test script, CI, or any editor that can spawn a process:

Get-Content lab\input.hl7 -Raw | bun xform.ts

Notepad++ wiring

PipeHat's External Transform Provider speaks exactly that contract. Add this to PipeHat.providers in the Notepad++ plugin config dir:

iris.command = bun.exe C:\opencode\iris-lab\xform.ts
iris.workdir = C:\opencode\iris-lab
iris.timeout = 20000
iris.desc    = InterSystems IRIS DTL

Then, with a message open and a second view showing:

  • Ctrl+Alt+Shift+X — pick the provider
  • Ctrl+Alt+Shift+A — run it again

The result lands in the other view and PipeHat diffs it field by field automatically. PipeHat never learns the word "InterSystems"; it just runs a command. Any other engine you wrap the same way is another line in that file.


The golden gate: Lab.Check

src/Lab.Check.cls is the regression harness. Give it a message and the output you expect, and it tells you whether the transform still produces it. It is the ObjectScript port of hl7-bench/check.ts, so a case written for one runs in the other.

It exists because a shared dev engine is usually remote. There is no filesystem you can drop a messages/ folder into, so the cases travel inside the class, in an XData block, and the whole gate is one file you can paste into Studio.

zn "USER"
do $system.OBJ.Load("C:\SIA\iris-lab\src\Lab.Check.cls","ck-d")
do ##class(Lab.Check).RunAll()          ; every case in the XData block
do ##class(Lab.Check).RunAll("a01")     ; only cases whose name contains "a01"
PASS  seed-a01  4 segments identical

1/1 cases passed

A failure prints the line number and both sides, which is the only output that saves you a trip to the Message Viewer:

FAIL  wrongwant
      line 3
        got  PID|1||MRN12345^^^LABMRN^LAB||DOE^john^q^^^^L||19800115|MALE|...
        want PID|9||MRN12345^^^LABMRN^MR||doe^john^q^^^^L||19800115|M|...

Writing a case

Cases are plain text in the Cases XData block, one ### directive per line:

### case seed-a01
### mask MSH:10
### in
MSH|^~\&|SENDAPP|...
PID|1||MRN12345|...
### want
MSH|^~\&|IRISLAB|...
PID|1||MRN12345|...

### mask is the part people skip and then fight for an hour. Any field the transform fills from the clock or a counter differs on every run, so the gate goes red on a transform that is perfectly correct. Mask those fields and they are blanked on both sides before comparison. MSH:10 (control ID) and MSH:7 (timestamp) are the usual two. Multiple fields go on one line, space separated, and the numbering is schema numbering, so MSH:10 is the control ID exactly as PipeHat shows it.

A case with ### reject and no ### want asserts that the transform refuses the message. Read the caveat below before trusting one.

Do not hand-write the ### want block. Generate it from a run you have already eyeballed:

do ##class(Lab.Check).Emit("C:\SIA\iris-lab\lab\input.hl7","seed-a01","MSH:10")

That prints a finished case block. Read it, confirm it is the output you actually wanted, then paste it into the XData. A golden captured from a run you never looked at freezes the bug in place and calls it the spec.

Cases in files instead

On a box where you do have a filesystem, RunDir reads the same layout hl7-bench uses, so bench cases and IRIS cases are the same files:

File Meaning
<name>.in.hl7 + <name>.want.hl7 Transform the first, expect the second
<name>.reject.hl7 Expect the transform to refuse it
<name>.mask Optional. Fields to blank, one per line or space separated
do ##class(Lab.Check).RunDir("C:\SIA\iris-lab\lab\checkcases")
do ##class(Lab.Check).RunDir("C:\SIA\iris-lab\lab\checkcases","a08")

An .in.hl7 with no matching .want.hl7 prints SKIP and is left out of the denominator, so a half-written case cannot pad the pass rate.

What a green rejection case does not mean

In hl7-bench, refusing a message is a real outcome. In IRIS it is not. A DTL is strictly one message in, one message out. It cannot filter and it cannot refuse. Deciding whether a message gets sent at all is the routing rule's job, and the routing rule is not in this class.

So a green reject case here means "the DTL also guards itself", which is worth having. It does not mean the interface refuses that event. If the rule sends an A03 to a transform that throws on A03, you have not filtered anything, you have manufactured an error queue. Test the filter in the rule, and test the guard here, and do not let one stand in for the other.

Status

Compiles and runs green on IRIS for Health 2026.1 (container). Every branch has been exercised deliberately, including both rejection outcomes, both mask offset rules, the skip path, and a filter that matches nothing. The MSH offset in MaskSegment is the subtle one: MSH-1 is the field separator, so MSH:3 is the third pipe-piece while PID:3 is the fourth. Get that backwards and the gate blanks the wrong field and still goes green, which is the worst outcome available to a test harness.


Coming from Mirth

Mirth / BridgeLink IRIS Note
Channel Production The container for everything
Source connector Business Service EnsLib.HL7.Service.TCPService for MLLP
Destination connector Business Operation EnsLib.HL7.Operation.TCPOperation
Filter + Router Business Process, usually a Routing Rule Rules are their own editor
Transformer step (JavaScript) DTL XML, drawn as a graphical mapper
msg['PID']['PID.5']['PID.5.1'] source.{PID:5.1} Same idea, different punctuation
channelMap / globalMap Production settings, Ens.Util.* No direct equivalent to channelMap
JavaScript escape hatch <code> block, ObjectScript Same role, different language
Message Browser Message Viewer / Visual Trace Visual Trace is genuinely better
Channel deploy Production start/update

The one that bites: MSH field numbering. In EnsLib.HL7, MSH:1 is the field separator, so MSH:9 is the message type and MSH:10 the control ID — the schema numbering, not the raw-pipe offset. PipeHat already honors this, so your instincts transfer.


Learning path

  1. Break it on purpose. Change {PID:5.1} to {PID:99.1} and run. Read the error. Do it again with a bad XML tag. Learn what each failure looks like now, while the loop is two seconds long.
  2. Work the DTL vocabulary<assign>, <if>, <foreach>, <subtransform>, <code>. The sample uses the first four.
  3. Open the same class in the Management Portal (Interoperability > Build > Data Transformations). Same file, graphical view. Edit it there, watch the XML change here. That connection is the thing that makes DTL click.
  4. Then leave the sandbox and build an actual production: TCP service, routing rule, TCP operation. Point PipeHat's MLLP sender at it — you already have the test harness.
  5. ObjectScript proper comes after DTL, not before. You will have absorbed half of it from <code> blocks by then.

Reference: Developing DTL Transformations · DTL for HL7 · EnsLib.HL7.Message class ref


Status

Run and green against IRIS for Health 2024.1.2 on a native Windows install, 2026-08-14. src/Lab.Runner.cls compiles and all five constructs in seed/Transform.cls fire: <assign> literal, <assign> with an ObjectScript expression, <if>/<true>, <foreach> over repeating fields, and a <code> block. The docker path has not been re-run since.

One real defect surfaced on first run, and it is the same failure recipe 18 documents. ApplySchema used named paths (MSH:12, MSH:9.1) to resolve the DocType — but a named path needs a resolved DocType, which is the entire job of that method. So it read empty off every message, ResolveSchemaTypeToDocType got ("", "_"), the guard skipped, and the bare catch swallowed it. No error, no throw, nothing in the log. The fix is ordinal paths (1:12, 1:9.1).

That was half a fix, so the other half landed later. ApplySchema now returns a %Status and distinguishes the four ways classification can fail, instead of producing identical silence for all of them. Failing to classify is still not fatal, because an unclassified message transforms perfectly well, but the run now says so:

??  UNCLASSIFIED  ERROR #5001: could not read MSH-12/MSH-9 off the message (version="" type="^")
OK  Lab.Transform    232 ms

That is the original bug, deliberately reintroduced, naming itself. "No schema loaded for 9.9:ADT_A01" is a different message for a different problem, which is the entire point. BuildMap() is checked now too: it returns a status that used to be discarded, and a message carrying a DocType it never built a map for is worse than an unclassified one, because named paths then resolve to empty rather than erroring and the next failure is silent as well.

The tell was a blank DocType in the OK line. Nothing else looked wrong, because the DTL declares its own sourceDocType/targetDocType on the <transform> element and does not depend on the runtime .DocType property — so the transform kept working correctly while the harness quietly failed to classify. Two independent things, one of them silent.

Debug the pieces by hand with:

docker exec -it iris-lab iris session IRIS      # container
.\lab.ps1                                       # native install

then zn "USER" and call the pieces by hand.

No PHI in lab/. Ever.

About

InterSystems IRIS DTL playground: six worked ObjectScript transformation recipes plus a harness that round-trips an HL7 message through a containerised IRIS instance.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages