@cosyte/astm 0.0.3 → 0.0.5
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/CHANGELOG.md +503 -67
- package/README.md +53 -21
- package/dist/index.cjs +301 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +883 -434
- package/dist/index.d.ts +883 -434
- package/dist/index.mjs +296 -69
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @cosyte/astm
|
|
2
2
|
|
|
3
|
-
> ASTM parser, serializer, and builder for Node.js and TypeScript
|
|
3
|
+
> ASTM parser, serializer, and builder for Node.js and TypeScript: **lenient on parse,
|
|
4
4
|
> spec-clean on emit**.
|
|
5
5
|
|
|
6
6
|
`@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
|
|
@@ -32,7 +32,9 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
|
32
32
|
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
33
33
|
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
34
34
|
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
35
|
-
construction
|
|
35
|
+
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
36
|
+
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
37
|
+
and lost.
|
|
36
38
|
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
37
39
|
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
38
40
|
ever altering a value, behind a definition-time safety gate that refuses to tolerate any result
|
|
@@ -67,7 +69,7 @@ A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>24
|
|
|
67
69
|
## Drive the transport (framed vs raw) + the LTP protocol
|
|
68
70
|
|
|
69
71
|
ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
|
|
70
|
-
vendor
|
|
72
|
+
vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
|
|
71
73
|
cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
|
|
72
74
|
pure protocol reducer with your own socket I/O.
|
|
73
75
|
|
|
@@ -83,7 +85,7 @@ import {
|
|
|
83
85
|
// 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
|
|
84
86
|
const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
|
|
85
87
|
if (framing === "raw") {
|
|
86
|
-
// cobas b121 raw-TCP: no handshake, no frames
|
|
88
|
+
// cobas b121 raw-TCP: no handshake, no frames, parse the record bytes directly.
|
|
87
89
|
parseAstmRecords(rawBytes);
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -93,19 +95,19 @@ function onControlOrFrame(event) {
|
|
|
93
95
|
const { state: next, actions, warnings } = ltpReduce(state, event);
|
|
94
96
|
state = next;
|
|
95
97
|
for (const a of actions) {
|
|
96
|
-
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK
|
|
97
|
-
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK
|
|
98
|
+
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK, only ever for a good frame
|
|
99
|
+
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK, bad checksum ⇒ retransmit
|
|
98
100
|
if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
|
|
99
101
|
}
|
|
100
|
-
void warnings; // ASTM_LTP_
|
|
102
|
+
void warnings; // ASTM_LTP_*, value-free (a code + at most a frame number)
|
|
101
103
|
}
|
|
102
104
|
// Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
|
|
103
105
|
```
|
|
104
106
|
|
|
105
107
|
The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
|
|
106
|
-
codec did not vouch for
|
|
108
|
+
codec did not vouch for (bad checksum, unterminated, or out of sequence) yields `sendNak`, **never** a
|
|
107
109
|
fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
|
|
108
|
-
The interactive contention/timeout/retransmit **timing** is the consumer's
|
|
110
|
+
The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
|
|
109
111
|
state transitions, not the wall-clock timers.
|
|
110
112
|
|
|
111
113
|
## Install
|
|
@@ -128,15 +130,45 @@ patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (th
|
|
|
128
130
|
msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
|
|
129
131
|
```
|
|
130
132
|
|
|
131
|
-
The parser is **lenient by default**
|
|
133
|
+
The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
|
|
132
134
|
produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
|
|
133
135
|
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
134
136
|
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
135
137
|
|
|
138
|
+
### Several messages in one stream
|
|
139
|
+
|
|
140
|
+
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
141
|
+
splits a parsed stream into them, and each entry carries only its own records, so a patient is only
|
|
142
|
+
ever paired with the results that message actually carried:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { parseAstmRecords, messages } from "@cosyte/astm";
|
|
146
|
+
|
|
147
|
+
for (const m of messages(parseAstmRecords(raw))) {
|
|
148
|
+
m.patient?.practiceAssignedId; // the P for THIS message
|
|
149
|
+
m.results; // the Rs for THIS message
|
|
150
|
+
m.delimiters; // the set THIS message's records were read with
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
|
|
155
|
+
so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
|
|
156
|
+
across patients:
|
|
157
|
+
|
|
158
|
+
- `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
|
|
159
|
+
- `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
|
|
160
|
+
one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
|
|
161
|
+
|
|
162
|
+
**Both are breaking**, and the second one reaches single-message callers: a lone message carrying
|
|
163
|
+
several patients used to answer with the first of them. A stream that is one message with at most one
|
|
164
|
+
patient is unchanged, and so is a result-only message with no `P` at all, which still answers
|
|
165
|
+
`undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
|
|
166
|
+
already names the message.
|
|
167
|
+
|
|
136
168
|
## Map local codes to LOINC (LIVD, bring-your-own)
|
|
137
169
|
|
|
138
170
|
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|
|
139
|
-
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message
|
|
171
|
+
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
|
|
140
172
|
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
141
173
|
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
142
174
|
|
|
@@ -155,31 +187,31 @@ warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free)
|
|
|
155
187
|
|
|
156
188
|
**No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
|
|
157
189
|
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
158
|
-
separately-licensed SNOMED CT
|
|
190
|
+
separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
|
|
159
191
|
its license obligations).
|
|
160
192
|
|
|
161
193
|
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
162
194
|
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
163
|
-
> instruments that reuse the same code for different analytes would both match
|
|
195
|
+
> instruments that reuse the same code for different analytes would both match: supply a catalog built
|
|
164
196
|
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
165
197
|
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
166
198
|
|
|
167
199
|
## The cosyte parser archetype
|
|
168
200
|
|
|
169
|
-
- **Postel's Law
|
|
201
|
+
- **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
|
|
170
202
|
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
171
|
-
- **Tiered tolerance
|
|
203
|
+
- **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
172
204
|
Tier 3 fatal always.
|
|
173
|
-
- **Stable warning codes
|
|
205
|
+
- **Stable warning codes**: warnings carry stable string codes + positional context; consumers
|
|
174
206
|
branch on `w.code`, so renaming a code is a breaking change.
|
|
175
|
-
- **Zero runtime dependencies
|
|
176
|
-
- **Dual ESM + CJS
|
|
177
|
-
- **Immutability
|
|
178
|
-
- **Profile system
|
|
207
|
+
- **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
|
|
208
|
+
- **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
|
|
209
|
+
- **Immutability**: parsed models are immutable; mutation is via explicit methods.
|
|
210
|
+
- **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
179
211
|
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
180
212
|
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
181
213
|
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
182
|
-
- **Terminology recognizer, not a dictionary
|
|
214
|
+
- **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
|
|
183
215
|
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
|
|
184
216
|
LOINC / SNOMED / LIVD data is bundled.
|
|
185
217
|
|