Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/first-minor-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@cosyte/ccda": minor
---

**This is 0.1.0, the first release whose public API we treat as settled.**

What is covered, and what you can build against:

- Reading C-CDA R2.1 documents: all twelve US Realm document types are recognized, the header and
the patient are typed, and fourteen entry families are extracted (problems, medications,
allergies, results, vital signs, immunizations, procedures, encounters, smoking status, plan of
treatment, functional status, mental status, family history and past medical history), each coded
value keeping the code system it was sent with.
- Lenient parsing with stable warning codes and a `{ strict: true }` mode that refuses the first
tolerated deviation; hostile XML (a DTD, entity expansion, an oversized or over-deep document) is
always refused.
- Building a spec-clean CCD, Referral Note or inpatient Discharge Summary with `buildCcda`, editing
a parsed document section by section with `editCcda` (which stamps a CDA R2 revision), and
re-serializing a parsed document, where parse and serialize are a fixed point.
- Required-section validation that reports, for every document type, how much of its obligation it
asserts, and UCUM checks on the quantities in results and vital signs.

What the version promises. The exported names, options, return shapes and warning codes are the
surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version
(0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public
value ships as a patch.

What is not covered yet. `buildCcda` emits three of the twelve document types and throws for the
other nine. Editing replaces or adds whole sections only: no entry-level append and no section
removal. Required-section validation under-warns for most document types, so a quiet parse is not a
conformance result. A terminology adapter is consulted at five coded slots only, and this package
ships value set identifiers, never their member codes. UCUM checking is grammatical over a curated
set of atoms.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,25 @@ jobs:
# bytes differ from what this run produced, so a behaviour that moved without its
# published statement moving with it reds here.
- run: pnpm conformance

# The runnable examples under examples/, checked the way a consumer meets them: each imports
# `@cosyte/ccda` by name, which resolves through package.json `exports` to the BUILT dist/, so
# the build runs first. Each example asserts its own output, so `pnpm examples` goes red when one
# drifts from the package. The PHI scan in the shared caller above already walks examples/.
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck:examples
- run: pnpm lint:examples
- run: pnpm examples
67 changes: 36 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ silence.

## Status

`package.json` declares **`0.0.15`**, on the pre-alpha `0.0.x` version ladder. What that version
claims is narrow and worth reading literally: the behaviour documented on this page is implemented
and covered by tests, but the public API is **not settled**, and any release may change it, so pin an
exact version rather than a range. These surfaces are named rather than implied:
`package.json` declares **`0.0.15`**. From `0.1.0` the public API is settled and safe to depend on:
the behaviour documented on this page is implemented and covered by tests, the exported names,
options, return shapes and warning codes are the surface we keep stable, and below 1.0 a breaking
change bumps the minor version and is called out in the changelog with its migration. On a `0.0.x`
version, pin an exact version rather than a range. Some surfaces are still partial, and they are
named rather than implied:

- **Building is partial.** `buildCcda` emits three of the twelve US Realm document types (CCD,
Referral Note and inpatient Discharge Summary); the other nine throw rather than emitting
Expand Down Expand Up @@ -90,16 +92,16 @@ substrate for C-CDA's XML.

## Usage

One example, end to end: build a complete, spec-clean CCD, read it back, and check what came out. The
`// =>` comments are the values this code really produces. A test in this repository executes this
block against the published entry point and fails if either side drifts, so a reader (or an agent)
lifting it verbatim gets code that runs.
One example, end to end: build a complete, spec-clean CCD, read it back, and print what came out.
The `// =>` comments are the values this code really produces, and the output below is what it
prints. Tests in this repository execute this block against the published entry point and fail if
either side drifts, so a reader (or an agent) lifting it verbatim gets code that runs.

```ts runnable
import { buildCcda, parseCcda } from "@cosyte/ccda";

// A complete synthetic CCD, built here so the example stands on its own. In your
// integration this is the document a sending system handed you.
// A synthetic CCD, built here so the example stands on its own. In your integration this is the
// document a sending system handed you.
const xml = buildCcda({
patient: { mrn: "MRN001", given: ["Jane"], family: "Doe", gender: "F", birthTime: "19800101" },
problems: [{ problem: { code: "59621000", displayName: "Essential hypertension" } }],
Expand All @@ -116,37 +118,40 @@ const xml = buildCcda({
reaction: { code: "247472004", displayName: "Hives" }, // SNOMED CT
},
],
vitalSigns: [
{
vitals: [
{
code: { code: "8480-6", displayName: "Systolic blood pressure" }, // LOINC
quantity: { value: 120, unit: "mm[Hg]" }, // UCUM
},
],
},
],
}).toString();

const doc = parseCcda(xml);
const problem = doc.getProblems()[0];
const allergen = doc.getAllergies()[0]?.allergies[0]?.allergen;

console.log(doc.documentType, doc.getMrn(), doc.getPatient()?.name?.family);
console.log("problem", problem?.problems[0]?.value?.code, problem?.status);
console.log("medication", doc.getMedications()[0]?.drug?.code);
console.log("allergy", allergen?.code, allergen?.displayName);
console.log("warnings", doc.warnings.length);
// The emit half is a fixed point: re-parsing and re-serializing changes nothing.
console.log("round trip unchanged", parseCcda(doc.toString()).toString() === xml);

// The values printed above, which the test suite asserts on every run:
doc.documentType; // => "ccd"
doc.getPatient()?.name?.family; // => "Doe"
doc.getMrn(); // => "MRN001"
doc.getProblems()[0]?.problems[0]?.value?.code; // => "59621000"
doc.getProblems()[0]?.status; // => "active"
problem?.problems[0]?.value?.code; // => "59621000"
problem?.status; // => "active"
doc.getMedications()[0]?.drug?.code; // => "314076"
doc.getAllergies()[0]?.allergies[0]?.allergen?.code; // => "7980"
allergen?.code; // => "7980"
doc.warnings; // => []
parseCcda(doc.toString()).toString() === xml; // => true
```

// An observation value is a discriminated union; switch on `kind` to reach the
// UCUM-checked quantity, with the document's own unit intact.
const systolic = doc.getVitals()[0]?.vitals[0]?.value;
systolic?.kind; // => "physicalQuantity"
systolic?.kind === "physicalQuantity" ? systolic.quantity?.value : undefined; // => 120
It prints:

// The emit half is a fixed point: re-parsing and re-serializing changes nothing.
parseCcda(doc.toString()).toString() === xml; // => true
```text
ccd MRN001 Doe
problem 59621000 active
medication 314076
allergy 7980 Penicillin G
warnings 0
round trip unchanged true
```

The parser is **lenient by default**: recoverable vendor quirks become stable-coded `CcdaWarning`s on
Expand Down
28 changes: 28 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Examples

Small runnable programs, one per job the package does. Each one imports `@cosyte/ccda` by its
published name, so it runs against the built package exactly as a consumer installs it, prints what
it read or built, and checks its own output: a mismatch exits non-zero. Every document in them is
synthetic, with an invented patient and fake OIDs.

Build once, then run them all:

```bash
pnpm install
pnpm build
pnpm examples
```

| File | What it shows | Run |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| [`read-a-ccd.ts`](read-a-ccd.ts) | Read a CCD: the document type, the patient and MRN, and the problems, medications and allergies with their code systems. | `pnpm tsx examples/read-a-ccd.ts` |
| [`build-and-round-trip.ts`](build-and-round-trip.ts) | Build a spec-clean CCD from typed input, read back a UCUM-checked vital sign, and see the re-serialized output match byte for byte. | `pnpm tsx examples/build-and-round-trip.ts` |
| [`lenient-and-strict.ts`](lenient-and-strict.ts) | Read a document with a missing medication route: lenient parsing keeps the data and reports a stable warning code, and strict mode refuses it. | `pnpm tsx examples/lenient-and-strict.ts` |
| [`edit-a-section.ts`](edit-a-section.ts) | Replace one section and add another with `editCcda`, getting back a CDA R2 revision of the source. | `pnpm tsx examples/edit-a-section.ts` |

`data/ccd.ts` exports the synthetic CCD three of the examples read. It is a copy of the fixture the
quickstart parses, kept in a module because this repository never tracks an XML document as a file.

CI runs `pnpm typecheck:examples`, `pnpm lint:examples` and `pnpm examples` after `pnpm build` on
every pull request, so an example that drifts from the package fails the build. The repository's
PHI scan walks the whole tree, `examples/` included.
65 changes: 65 additions & 0 deletions examples/build-and-round-trip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Build a spec-clean CCD from typed input, read it back, and show that the emit half is a fixed
* point: re-parsing and re-serializing the output changes nothing.
*
* `buildCcda` emits the six sections a CCD requires, each with the template the specification asks
* for, so the result reads back with no warning. Every value here is synthetic.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/build-and-round-trip.ts
*/

import assert from "node:assert/strict";

import { buildCcda, parseCcda, requiredSectionKeys } from "@cosyte/ccda";

const xml = buildCcda({
patient: { mrn: "MRN001", given: ["Jane"], family: "Doe", gender: "F", birthTime: "19800101" },
problems: [{ problem: { code: "59621000", displayName: "Essential hypertension" } }],
medications: [
{
drug: { code: "314076", displayName: "Lisinopril 10 MG Oral Tablet" }, // RxNorm
dose: { value: 1, unit: "{tablet}" },
route: { code: "C38288", displayName: "Oral" }, // NCI Thesaurus
},
],
allergies: [
{
allergen: { code: "7980", displayName: "Penicillin G" }, // RxNorm
reaction: { code: "247472004", displayName: "Hives" }, // SNOMED CT
},
],
vitalSigns: [
{
vitals: [
{
code: { code: "8480-6", displayName: "Systolic blood pressure" }, // LOINC
quantity: { value: 120, unit: "mm[Hg]" }, // UCUM
},
],
},
],
}).toString();

const doc = parseCcda(xml);
console.log(`Built ${String(doc.documentType)}: ${String(xml.length)} characters of XML`);
console.log("Sections:", doc.sections.map((s) => s.key ?? "(unrecognized)").join(", "));

// An observation value is a discriminated union: switch on `kind` to reach the UCUM quantity.
const systolic = doc.getVitals()[0]?.vitals[0]?.value;
if (systolic?.kind === "physicalQuantity") {
console.log(`Systolic: ${String(systolic.quantity?.value)} ${String(systolic.quantity?.unit)}`);
}

const fixedPoint = parseCcda(doc.toString()).toString() === xml;
console.log("Re-serialized unchanged:", fixedPoint);

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
assert.equal(doc.documentType, "ccd");
assert.equal(doc.getMrn(), "MRN001");
assert.equal(systolic?.kind, "physicalQuantity");
assert.ok(fixedPoint);
// Every section a CCD requires is present, whatever order the builder writes them in.
assert.deepEqual(doc.sections.map((s) => s.key).sort(), [...requiredSectionKeys("ccd")].sort());
assert.equal(doc.warnings.length, 0);
Loading
Loading