How interface code works
An interface's code is a TypeScript file in its folder, named by
script in its interface.yaml. The engine calls one of
its functions for each message and each destination, and sends on what the function returns. This
page is how that works; the pages after it are the tasks: routing and
filtering, mapping one message to another,
formats, lookups and code tables and testing.
Say it in YAML first. Much of what interface code does in other engines is a setting here,
checked by ductus validate and visible to ductus impact without reading any code. Write code
for what's left:
| You want to | Without code | Page |
|---|---|---|
| Send each message to several places, each with its own queue and retries | destinations | Destinations |
| Pass a feed into another team's interface | destination.interface and source: internal | Routes and filters |
| Keep one patient's messages in order | ordering.key: PID-3.1 | Delivery |
| Ignore a sender's repeat after a lost ACK | dedupe.keys: [MSH-10] | Delivery |
| Read CSV, fixed-width, JSON or XML; write another of them | format and output | Formats |
| Split a batch file into one message per record | split: records on a file source | Sources |
| Relay messages as they came | no script at all | below |
What needs code: deciding which messages a destination gets, changing fields, building a message in another shape, and looking things up. There is no field-mapping screen or mapping file: the mapping is code, and a mapping can still be written as a table in that code (transforms), reviewed in Git and tested like the rest.
What the engine does with each message
- A sender's message arrives and is stored in the database; only then does the sender get
its acknowledgement. Nothing about your code delays or decides the ACK (except an HTTP source
in
respondmode, below). - For each destination, in its own queue and order, a worker calls that destination's
function with the message as it was received, parsed by the interface's
format. - What the function returns decides what happens, for that destination only:
| The function | The delivery is | Shown as |
|---|---|---|
| returns a message, an object or a string | sent: written in the destination's output format and delivered | sent |
returns null (or nothing) | filtered: never sent, kept with the message for audit | filtered |
returns kill("reason") | filtered, with the reason shown on the message | filtered: reason |
throws quarantine("reason") | quarantined: held for a person to look at, resend, correct or dismiss | quarantined: reason |
| throws any other error | quarantined, with the error's text | quarantined: error |
| meets a temporary failure (a database or API that's down, an HTTP 5xx, 408 or 429) | waiting: tried again later, in order, with nothing sent meanwhile in its lane | pending, with the error |
- A message is finished when every destination is done with it: sent, filtered or quarantined. Its overall status is quarantined if any destination quarantined it, else sent if any sent it, else filtered.
So a function's job is to say what this destination should get, or that it gets nothing, or
that a person must look. Never swallow a problem with return null: a message nobody can see
again is the failure people find months later. Quarantine it, with a reason a person can act on
(nuance).
The functions
// interfaces/adt-in/route.ts, in part
export function toLab(msg: Message) {
if (!LAB_EVENTS.has(msg.MSH[9][2])) return null; // not for the lab: filtered, no reason needed
if (msg.PID[3][1].startsWith("TEST")) return kill("test patient");
transform(msg, meta)is the function a destination calls unless it names another withtransform:. With several destinations, export one function per destination (toLab,toBilling) and name each; one file can hold them all.- The first argument is the message parsed by
format: aMessagefor HL7, an object or array for JSON, anXmlElementfor XML, a record (or a list of them) for CSV and fixed-width, a string fortext(formats). - The second,
meta, is about this message:id(the stored message's id, the same on every retry and for every destination: give it to receivers that can ignore a repeat),receivedAt,destination(which destination this call is for) andorigin(for a message routed from another interface, its id there). - A function may be
asyncandawaitlookups (lookups). While one waits, the thread runs other messages. - What it returns is written in the destination's output format: a
Messageor anXmlElementas itself, an object as JSON, records as CSV or fixed-width lines, and a string as it is, whatever the format. - Without a
script, every destination gets the message exactly as received.
What code may and may not do
-
It may only import project files and
@ductus/sdk. No npm packages, no network or files of its own: databases, APIs, variables and secrets come through the SDK (db(),api(),fetch(),env(),secret()), so the engine can time them, retry them, mock them in tests and list them inductus impact. -
It can run more than once for one message: after a temporary failure, a restart in the middle, a resend or a replay (nuance). So it must give the same answer for the same message, and anything it writes elsewhere (
db().execute, an APIPOST) must be safe to repeat: usemeta.idas the key. -
It shouldn't run long. A function that runs past
timeout(5 s by default) without pausing is stopped, its message quarantined, and three in a row stop the interface (runaway scripts). Waiting on a lookup doesn't count. -
Missing fields read as
""and never throw, so a typo in a field number gives""quietly: test with real messages and check the fields you map (nuance). -
timeoutcounts time the code runs, not time it waits on anawait: a function waiting on a slow lookup isn't stopped for it (nuance). Code that runs away after anawaitinterrupts every async call on its thread (nuance). -
An import used only for what it does when it loads is removed by TypeScript: import something from it you use, or call a function it exports (nuance).
Code shared between interfaces
A file outside any interface folder, such as lib/codes.ts, can be imported by several
interfaces (import { translate } from "../../lib/codes.ts"). Use it for code tables, mappings
and helpers that more than one interface needs. Two rules
(shared pool):
- Keep it read-only: tables as
const, functions that only use their arguments. A top-levelletis one copy shared by every interface on that thread, andductus validatewarns about it (DX3). - A heavy loop belongs in the interface's own file: a runaway in a shared file after an
awaitcan't be blamed on one interface (nuance).
All interfaces in an engine share one global scope per thread, so a global set by one is seen by others (nuance), and a shared library file is one copy per thread, not one per project (nuance).
A new interface, and the editor
ductus new interface orders-in (with --source mllp|http|file|sftp|schedule and --format)
writes a working interface: its interface.yaml, a transform.ts, a test and a sample message,
passing ductus test and ductus validate as written. Start from there.
ductus test and ductus validate write the SDK's typings into .ductus/types and a
tsconfig.json, so VS Code (or any editor that speaks TypeScript) completes msg.PID[ with the
fields' names and underlines mistakes as you type. After an upgrade, run ductus types so the
editor knows the new version (nuance).
The examples on these pages
The code on these pages is from a small project in the repository, examples/guides: ADT routed
to a lab and to billing, results to a FHIR server, an X12 835 to CSV, a census file to HL7. A
code block that starts with a file's path, such as // interfaces/adt-in/route.ts, is a piece
of that file, and the docs' tests check that the page and the file agree and that the project's
own tests pass. Copy it, run ductus test in it, and change it.