fhir-data-utils-ts 0.2.3

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.
@@ -0,0 +1,237 @@
1
+ import { buildFlatClaimResourceEntry } from './flat-claim-resource-graph.js';
2
+ import { FhirCodeSystem, ObservationCategoryCodes, ObservationClaim, decodeObservationClaimList, encodeObservationClaimList, } from './observation-claims.js';
3
+ export { ObservationCategoryCodes, ObservationClaim } from './observation-claims.js';
4
+ function defineCoding(system, code, display) {
5
+ return Object.freeze({ system, code, display, claim: `${system}|${code}` });
6
+ }
7
+ /** Canonical LOINC descriptors for supported vital signs. */
8
+ export const VitalSignsCodes = Object.freeze({
9
+ BodyWeight: defineCoding(FhirCodeSystem.Loinc, '29463-7', 'Body weight'),
10
+ HeartRate: defineCoding(FhirCodeSystem.Loinc, '8867-4', 'Heart rate'),
11
+ BloodPressure: defineCoding(FhirCodeSystem.Loinc, '85354-9', 'Blood pressure panel'),
12
+ SystolicBloodPressure: defineCoding(FhirCodeSystem.Loinc, '8480-6', 'Systolic blood pressure'),
13
+ DiastolicBloodPressure: defineCoding(FhirCodeSystem.Loinc, '8462-4', 'Diastolic blood pressure'),
14
+ BodyTemperature: defineCoding(FhirCodeSystem.Loinc, '8310-5', 'Body temperature'),
15
+ OxygenSaturation: defineCoding(FhirCodeSystem.Loinc, '2708-6', 'Oxygen saturation'),
16
+ RespiratoryRate: defineCoding(FhirCodeSystem.Loinc, '9279-1', 'Respiratory rate'),
17
+ });
18
+ /** Canonical UCUM descriptors for supported vital signs. */
19
+ export const VitalSignsUnits = Object.freeze({
20
+ BeatsPerMinute: defineCoding(FhirCodeSystem.Ucum, '/min', 'beats/minute'),
21
+ MillimeterOfMercury: defineCoding(FhirCodeSystem.Ucum, 'mm[Hg]', 'mmHg'),
22
+ Celsius: defineCoding(FhirCodeSystem.Ucum, 'Cel', 'degrees Celsius'),
23
+ Percent: defineCoding(FhirCodeSystem.Ucum, '%', 'percent'),
24
+ Kilogram: defineCoding(FhirCodeSystem.Ucum, 'kg', 'kilogram'),
25
+ });
26
+ /** Governed FHIR/LOINC tokens used by every channel that captures vital signs. */
27
+ export const VitalSignCode = Object.freeze({
28
+ bloodPressure: VitalSignsCodes.BloodPressure.claim,
29
+ systolic: VitalSignsCodes.SystolicBloodPressure.claim,
30
+ diastolic: VitalSignsCodes.DiastolicBloodPressure.claim,
31
+ temperature: VitalSignsCodes.BodyTemperature.claim,
32
+ heartRate: VitalSignsCodes.HeartRate.claim,
33
+ oxygenSaturation: VitalSignsCodes.OxygenSaturation.claim,
34
+ respiratoryRate: VitalSignsCodes.RespiratoryRate.claim,
35
+ bodyWeight: VitalSignsCodes.BodyWeight.claim,
36
+ });
37
+ /** UCUM-compatible unit spellings persisted in flat claims. */
38
+ export const VitalSignUnit = Object.freeze({
39
+ bloodPressure: VitalSignsUnits.MillimeterOfMercury.claim,
40
+ temperature: VitalSignsUnits.Celsius.claim,
41
+ heartRate: VitalSignsUnits.BeatsPerMinute.claim,
42
+ oxygenSaturation: VitalSignsUnits.Percent.claim,
43
+ respiratoryRate: VitalSignsUnits.BeatsPerMinute.claim,
44
+ bodyWeight: VitalSignsUnits.Kilogram.claim,
45
+ });
46
+ /**
47
+ * Builds one timestamped Observation. The device is retained as source; this
48
+ * helper deliberately never creates a professional performer or attester.
49
+ */
50
+ export function buildVitalSignObservation(input) {
51
+ const measuredAt = normalizeDateTime(input.measuredAt);
52
+ const claims = {
53
+ [ObservationClaim.Identifier]: required(input.entryId, 'vital_sign_entry_id_required'),
54
+ [ObservationClaim.Subject]: required(input.subjectReference, 'vital_sign_subject_required'),
55
+ [ObservationClaim.Status]: input.status,
56
+ [ObservationClaim.Category]: ObservationCategoryCodes.VitalSigns.claim,
57
+ [ObservationClaim.EffectiveDateTime]: measuredAt,
58
+ };
59
+ if (input.deviceReference)
60
+ claims[ObservationClaim.Device] = required(input.deviceReference, 'vital_sign_device_required');
61
+ if (input.note?.trim())
62
+ claims[ObservationClaim.Note] = input.note.trim();
63
+ if (input.userSelected !== undefined)
64
+ claims[ObservationClaim.UserSelected] = String(input.userSelected);
65
+ if (input.kind === 'blood-pressure') {
66
+ if (!validPositive(input.systolic) || !validPositive(input.diastolic) || input.systolic <= input.diastolic) {
67
+ throw new TypeError('blood_pressure_values_invalid');
68
+ }
69
+ claims[ObservationClaim.Code] = VitalSignCode.bloodPressure;
70
+ claims[ObservationClaim.CodeDisplay] = VitalSignsCodes.BloodPressure.display ?? '';
71
+ claims[ObservationClaim.ComponentCode] = encodeObservationClaimList([VitalSignCode.systolic, VitalSignCode.diastolic]);
72
+ claims[ObservationClaim.ComponentCodeDisplay] = encodeObservationClaimList([
73
+ VitalSignsCodes.SystolicBloodPressure.display ?? '',
74
+ VitalSignsCodes.DiastolicBloodPressure.display ?? '',
75
+ ]);
76
+ claims[ObservationClaim.ComponentValueQuantityNumber] = encodeObservationClaimList([String(input.systolic), String(input.diastolic)]);
77
+ claims[ObservationClaim.ComponentValueQuantityUnit] = encodeObservationClaimList([VitalSignUnit.bloodPressure, VitalSignUnit.bloodPressure]);
78
+ }
79
+ else {
80
+ if (!validPositive(input.value))
81
+ throw new TypeError('vital_sign_value_invalid');
82
+ const scalar = scalarDefinition(input.kind);
83
+ claims[ObservationClaim.Code] = scalar.coding.claim;
84
+ if (scalar.coding.display)
85
+ claims[ObservationClaim.CodeDisplay] = scalar.coding.display;
86
+ claims[ObservationClaim.ValueQuantityNumber] = String(input.value);
87
+ claims[ObservationClaim.ValueQuantityUnit] = scalar.unit;
88
+ }
89
+ return buildFlatClaimResourceEntry({ entryId: input.entryId, resourceType: 'Observation', claims });
90
+ }
91
+ /** Restores aligned FHIR component values from canonical JSON or legacy CSV. */
92
+ export function parseObservationComponents(claims) {
93
+ const codes = decodeObservationClaimList(claims[ObservationClaim.ComponentCode]);
94
+ const displays = decodeObservationClaimList(claims[ObservationClaim.ComponentCodeDisplay]);
95
+ const values = decodeObservationClaimList(claims[ObservationClaim.ComponentValueQuantityNumber]);
96
+ const units = decodeObservationClaimList(claims[ObservationClaim.ComponentValueQuantityUnit]);
97
+ if (!codes.length && claims[ObservationClaim.Code] === VitalSignCode.bloodPressure) {
98
+ const systolic = Number(claims[ObservationClaim.BloodPressureSystolicNumber]);
99
+ const diastolic = Number(claims[ObservationClaim.BloodPressureDiastolicNumber]);
100
+ if (Number.isFinite(systolic) && Number.isFinite(diastolic)) {
101
+ return Object.freeze([
102
+ Object.freeze({ code: VitalSignCode.systolic, value: systolic, unit: VitalSignUnit.bloodPressure }),
103
+ Object.freeze({ code: VitalSignCode.diastolic, value: diastolic, unit: VitalSignUnit.bloodPressure }),
104
+ ]);
105
+ }
106
+ }
107
+ if (codes.length !== values.length || codes.length !== units.length)
108
+ throw new TypeError('observation_components_not_aligned');
109
+ if (displays.length && displays.length !== codes.length)
110
+ throw new TypeError('observation_components_not_aligned');
111
+ return Object.freeze(codes.map((code, index) => {
112
+ const value = Number(values[index]);
113
+ if (typeof code !== 'string' || typeof units[index] !== 'string' || !Number.isFinite(value)) {
114
+ throw new TypeError('observation_component_invalid');
115
+ }
116
+ const display = displays[index];
117
+ if (display !== undefined && typeof display !== 'string')
118
+ throw new TypeError('observation_component_invalid');
119
+ return Object.freeze({ code, ...(display ? { display } : {}), value, unit: units[index] });
120
+ }));
121
+ }
122
+ /**
123
+ * Creates generic reduced rows for component-level indexes.
124
+ *
125
+ * These are internal index projections, not independent native FHIR
126
+ * Observations. The missing status deliberately keeps them out of normal
127
+ * top-level Observation results.
128
+ */
129
+ export function materializeObservationComponentIndexEntries(entry) {
130
+ if (entry.resourceType !== 'Observation')
131
+ throw new TypeError('vital_sign_observation_required');
132
+ const subject = required(entry.claims[ObservationClaim.Subject] ?? '', 'vital_sign_subject_required');
133
+ return Object.freeze(parseObservationComponents(entry.claims).map((component, componentIndex) => Object.freeze({
134
+ parentReference: entry.reference,
135
+ componentIndex,
136
+ claims: Object.freeze({
137
+ [ObservationClaim.Subject]: subject,
138
+ [ObservationClaim.Code]: component.code,
139
+ ...(component.display ? { [ObservationClaim.CodeDisplay]: component.display } : {}),
140
+ [ObservationClaim.ValueQuantityNumber]: String(component.value),
141
+ [ObservationClaim.ValueQuantityUnit]: component.unit,
142
+ }),
143
+ })));
144
+ }
145
+ /** Projects the neutral flat contract at the explicit FHIR R4 boundary. */
146
+ export function projectVitalSignObservationR4(entry) {
147
+ return projectVitalSignObservation(entry);
148
+ }
149
+ /** Projects the neutral flat contract at the explicit FHIR R5 boundary. */
150
+ export function projectVitalSignObservationR5(entry) {
151
+ return projectVitalSignObservation(entry);
152
+ }
153
+ function projectVitalSignObservation(entry) {
154
+ if (entry.resourceType !== 'Observation')
155
+ throw new TypeError('vital_sign_observation_required');
156
+ const claims = entry.claims;
157
+ const base = {
158
+ resourceType: 'Observation',
159
+ id: required(claims[ObservationClaim.Identifier] ?? '', 'vital_sign_entry_id_required'),
160
+ status: required(claims[ObservationClaim.Status] ?? '', 'vital_sign_status_required'),
161
+ category: [{ coding: [codingFromClaim(required(claims[ObservationClaim.Category] ?? '', 'vital_sign_category_required'))] }],
162
+ code: { coding: [codingFromClaim(required(claims[ObservationClaim.Code] ?? '', 'vital_sign_code_required'), claims[ObservationClaim.CodeDisplay])] },
163
+ subject: { reference: required(claims[ObservationClaim.Subject] ?? '', 'vital_sign_subject_required') },
164
+ effectiveDateTime: required(claims[ObservationClaim.EffectiveDateTime] ?? '', 'vital_sign_measured_at_required'),
165
+ ...(claims[ObservationClaim.Device] ? { device: { reference: claims[ObservationClaim.Device] } } : {}),
166
+ };
167
+ const components = parseObservationComponents(claims);
168
+ if (components.length) {
169
+ return Object.freeze({ ...base, component: Object.freeze(components.map(component => Object.freeze({
170
+ code: { coding: [codingFromClaim(component.code, component.display)] },
171
+ valueQuantity: quantity(component.value, component.unit),
172
+ }))) });
173
+ }
174
+ const value = Number(claims[ObservationClaim.ValueQuantityNumber]);
175
+ if (!Number.isFinite(value))
176
+ throw new TypeError('vital_sign_value_invalid');
177
+ return Object.freeze({ ...base, valueQuantity: quantity(value, required(claims[ObservationClaim.ValueQuantityUnit] ?? '', 'vital_sign_unit_required')) });
178
+ }
179
+ function codingFromClaim(value, display) {
180
+ const separator = value.lastIndexOf('|');
181
+ return separator < 0
182
+ ? Object.freeze({ code: value, ...(display ? { display } : {}) })
183
+ : Object.freeze({ system: value.slice(0, separator), code: value.slice(separator + 1), ...(display ? { display } : {}) });
184
+ }
185
+ function quantity(value, unitClaim) {
186
+ const coding = codingFromClaim(unitClaim);
187
+ return Object.freeze({ value, unit: coding.code, ...coding });
188
+ }
189
+ /** Converts a device/API batch to independent preliminary Observations without aggregation. */
190
+ export function normalizeVitalSignDeviceBatch(input) {
191
+ const seen = new Set();
192
+ return Object.freeze(input.readings.map(reading => {
193
+ if (seen.has(reading.entryId))
194
+ throw new TypeError('vital_sign_entry_id_duplicate');
195
+ seen.add(reading.entryId);
196
+ return buildVitalSignObservation({
197
+ ...reading,
198
+ subjectReference: input.subjectReference,
199
+ deviceReference: input.deviceReference,
200
+ status: 'preliminary',
201
+ });
202
+ }));
203
+ }
204
+ function scalarDefinition(kind) {
205
+ switch (kind) {
206
+ case 'temperature': return { coding: VitalSignsCodes.BodyTemperature, unit: VitalSignUnit.temperature };
207
+ case 'heart-rate': return { coding: VitalSignsCodes.HeartRate, unit: VitalSignUnit.heartRate };
208
+ case 'oxygen-saturation': return { coding: VitalSignsCodes.OxygenSaturation, unit: VitalSignUnit.oxygenSaturation };
209
+ case 'respiratory-rate': return { coding: VitalSignsCodes.RespiratoryRate, unit: VitalSignUnit.respiratoryRate };
210
+ case 'body-weight': return { coding: VitalSignsCodes.BodyWeight, unit: VitalSignUnit.bodyWeight };
211
+ }
212
+ }
213
+ function normalizeDateTime(value) {
214
+ const date = new Date(value);
215
+ if (!value.trim() || Number.isNaN(date.valueOf()))
216
+ throw new TypeError('vital_sign_measured_at_invalid');
217
+ return date.toISOString();
218
+ }
219
+ function validPositive(value) {
220
+ return Number.isFinite(value) && value > 0;
221
+ }
222
+ function required(value, error) {
223
+ const normalized = value.trim();
224
+ if (!normalized || normalized.includes(','))
225
+ throw new TypeError(error);
226
+ return normalized;
227
+ }
228
+ /** Reusable neutral fixtures; consumers and tests must not duplicate wire examples. */
229
+ export const VITAL_SIGN_EXAMPLES = Object.freeze({
230
+ subjectReference: 'Patient/example-subject',
231
+ deviceReference: 'Device/example-home-monitor',
232
+ bloodPressure: Object.freeze({ entryId: 'bp-example-1', measuredAt: '2026-09-21T08:15:00-07:00', systolic: 121, diastolic: 79 }),
233
+ heartRateBatch: Object.freeze([
234
+ Object.freeze({ entryId: 'hr-example-1', kind: 'heart-rate', measuredAt: '2026-09-20T08:00:00Z', value: 68 }),
235
+ Object.freeze({ entryId: 'hr-example-2', kind: 'heart-rate', measuredAt: '2026-09-21T08:00:00Z', value: 72 }),
236
+ ]),
237
+ });
@@ -0,0 +1,68 @@
1
+ # UHC FHIR utilities migration
2
+
3
+ `uhc-fhir-utils-typescript` is an archaeological source, not a dependency of
4
+ this package. Its useful behavior must be recovered behind neutral, typed and
5
+ version-explicit contracts. Its product branding, obsolete security choices
6
+ and transport assumptions must not be copied.
7
+
8
+ ## Migration rules
9
+
10
+ 1. Add the smallest failing contract test here before porting behavior.
11
+ 2. Use official FHIR R4/R5/R6 structures at import/export boundaries and flat
12
+ claims as the version-neutral representation between those boundaries.
13
+ 3. Copy no raw claim, terminology, identifier or profile literal into a
14
+ consumer. Define it once in its owning neutral or sector data package.
15
+ 4. Preserve historical inputs with explicit read adapters. New writers emit
16
+ only the current canonical representation.
17
+ 5. Do not change `gdc-*` while those repositories are frozen. They are
18
+ read-only compatibility evidence until every consumer uses this package.
19
+
20
+ ## Reuse, rewrite or retire
21
+
22
+ | Old area | Decision | Current destination or gate |
23
+ | --- | --- | --- |
24
+ | `Bundle` and `Composition` traversal | Rewrite from tests | Neutral graph and explicit R4/R5/R6 adapters; preserve author, attester and section boundaries |
25
+ | `CodeableConcept`, `Identifier`, `Reference`, `Quantity` | Rewrite and reuse behavior | Small typed value modules with round-trip tests |
26
+ | `Observation` and resource parameter models | Rewrite | Canonical claim catalogs plus version-specific projection adapters |
27
+ | `Parameters` conversion | Rewrite | A typed FHIR Parameters module; never a transport-specific envelope |
28
+ | Anonymization field inventory | Test input only | A separately reviewed policy contract; old field lists do not become policy automatically |
29
+ | ATC, LOINC, SNOMED and EMA JSON | Validate before reuse | Terminology service fixtures, never authoritative embedded catalogs |
30
+ | DICOM parsing | Extract later | Separate imaging package; do not add `dicom-parser` to the neutral core |
31
+ | SHA-1 attachment hashing | Retire | No compatibility writer; historical read/verification belongs in a versioned adapter |
32
+ | `MessageHeader` transport conventions | Retire | Transport is outside the FHIR data contract |
33
+ | UHC-branded templates and defaults | Retire or move downstream | Product policy belongs in UHC packages, not here |
34
+ | Babel/Yarn/Jest build plumbing and broad `any` models | Retire | Node 24, TypeScript strict mode and `node:test` |
35
+
36
+ ## Observation and component contract
37
+
38
+ FHIR blood pressure is one `Observation` with systolic and diastolic entries in
39
+ `Observation.component[]`. The flat representation stores aligned repeatable
40
+ component claims as JSON arrays because every flat value is a string and CSV
41
+ cannot represent arbitrary displays or text without ambiguity.
42
+
43
+ Indexing may derive one reduced internal row per component. Those rows use the
44
+ generic component code, numeric value and unit claims, carry no
45
+ `Observation.status`, and are not exported as independent FHIR Observations.
46
+ They retain their parent reference and ordinal but do not invent a persistent
47
+ identifier. A persistence adapter must preserve an existing historical
48
+ identifier or apply its own explicitly versioned identity policy. Canonical
49
+ component arrays on the parent remain the reconstruction source.
50
+
51
+ The historical `Observation.bp-systolic-number` and
52
+ `Observation.bp-diastolic-number` fields are accepted only when reading data
53
+ that has no canonical component claims. New writers and generic component
54
+ indexes never emit them. This prevents a new custom claim family for every
55
+ assessment scale.
56
+
57
+ ## Remaining migration sequence
58
+
59
+ 1. Inventory imports from `gdc-common-utils-ts` in SOS, Vet, UHC, portals and
60
+ assistants by resource family.
61
+ 2. Move one neutral resource family at a time into this package with exact
62
+ compatibility vectors and R4/R5 projection tests.
63
+ 3. Release this package, pin the immutable version in the lowest downstream
64
+ package, and run that consumer's complete local matrix.
65
+ 4. Remove the corresponding frozen dependency only after no consumer imports
66
+ that family from it.
67
+ 5. Migrate imaging/DICOM and terminology payloads as separate changes because
68
+ they have different dependencies, security rules and release gates.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "fhir-data-utils-ts",
3
+ "version": "0.2.3",
4
+ "description": "FHIR-version-neutral flat-claim and SearchParameter data utilities",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./coding-review-flat-claims": {
15
+ "types": "./dist/coding-review-flat-claims.d.ts",
16
+ "default": "./dist/coding-review-flat-claims.js"
17
+ },
18
+ "./flat-claim-resource-graph": {
19
+ "types": "./dist/flat-claim-resource-graph.d.ts",
20
+ "default": "./dist/flat-claim-resource-graph.js"
21
+ },
22
+ "./search-parameters": {
23
+ "types": "./dist/search-parameters.d.ts",
24
+ "default": "./dist/search-parameters.js"
25
+ },
26
+ "./observation-claims": {
27
+ "types": "./dist/observation-claims.d.ts",
28
+ "default": "./dist/observation-claims.js"
29
+ },
30
+ "./vital-sign-observations": {
31
+ "types": "./dist/vital-sign-observations.d.ts",
32
+ "default": "./dist/vital-sign-observations.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ ".codex/skills",
38
+ "docs",
39
+ "README.md",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "scripts": {
43
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
44
+ "build": "npm run clean && tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "test": "npm run build && node --test tests/*.test.mjs",
47
+ "check": "npm run typecheck && npm test",
48
+ "prepack": "npm run check"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^24.10.0",
52
+ "typescript": "^5.9.3"
53
+ },
54
+ "engines": {
55
+ "node": ">=24"
56
+ },
57
+ "private": false,
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }