@opencharts/mapper
v0.0.1
Published
Mapper utilities for OpenCharts
Maintainers
Readme
@opencharts/mapper
Pure, deterministic record-to-canonical-resource mapper with a fluent DSL.
Overview
@opencharts/mapper maps source records (CSV rows, FHIR resources, API responses, …) to canonical resource instances declaratively. It is:
- Pure & deterministic: same
(mapping, record)always yields the same output. No I/O, no clocks, no global state. - Source-agnostic: the mapper only sees a flat key→value record. Source format is irrelevant.
- Catalog-decoupled: catalog validation is opt-in (
validateMappedResources), somap()stays pure.
What's included
| Export | Description |
|---|---|
| PureResourceMapper | The mapping engine |
| PureMappingDefinition | Validated mapping declaration |
| PureMappingRegistry | Store and look up mapping definitions by id or source |
| dsl | Fluent authoring DSL (namespace) |
| resolveBinding, validateBinding, isBinding | Binding primitives |
| validateMappedResources | Opt-in catalog validation for mapper output |
Installation
npm install @opencharts/mapperQuick Start
import { PureResourceMapper, dsl as d } from '@opencharts/mapper';
const mapper = new PureResourceMapper();
const { resources } = mapper.map(
{
id: 'patient-feed', version: 1,
source: { type: 'csv', system: 'epic' },
resources: [{
key: 'patient',
type: 'Patient',
parameters: {
identifiers: d.list(
d.record({ system: d.constant('mrn'), value: d.read('MRN').trim() })
),
birth_date: d.read('DOB').asIsoDate(),
gender: d.read('SEX').asGender(),
},
}],
},
{ MRN: ' 12345 ', DOB: '19850101', SEX: 'F' },
);
console.log(resources[0].parameters);
// { identifiers: [{ system: 'mrn', value: '12345' }], birth_date: '1985-01-01', gender: 'female' }The Binding Grammar
A binding is a plain object with exactly one kind key:
| Kind | Shape | Description |
|---|---|---|
| from | { from: 'field', required?, default? } | Read record[field] |
| literal | { literal: value } | Constant value |
| template | { template: 'hello {name}' } | Substitute {key} from the record |
| object | { object: { k: binding\|value } } | Build a nested object |
| array | { array: [binding\|value, …] } | Build a list |
| concat | { concat: [binding\|value, …] } | Flat-merge multiple array sources |
| each | { each: binding, as?: binding } | Iterate an array-valued source |
| coalesce | { coalesce: [binding, …] } | First non-null value |
| if | { if: condition, then: binding, else?: binding } | Conditional |
| select | { select: [{when, then}], else?: binding } | Multi-branch |
Any binding can carry transforms: [{ name, version }] applied via the normalizer registry.
DSL Reference
Value builders
import { dsl as d } from '@opencharts/mapper';
d.read('FieldName') // { from: 'FieldName' }
d.constant(42) // { literal: 42 }
d.template('{First} {Last}') // { template: '{First} {Last}' }
d.record({ k: expr }) // { object: { k: expr } }
d.list(a, b, c) // { array: [a, b, c] }
d.concat(a, b) // { concat: [a, b] } (flat merge)
d.each(source, as?) // { each: source, as }
d.coalesce(a, b, c) // first non-null
d.when(cond, then, else?) // conditional
d.select([{when, then}], else?) // multi-branchChained transforms on read()
d.read('DOB').asIsoDate() // → YYYY-MM-DD
d.read('SEX').asGender() // → FHIR AdministrativeGender
d.read('Name').trim()
d.read('IDS').split({ delimiter: ',' })
d.read('PAIRS').delimited({ item: ',', pair: '|', fields: ['value','system'] })
d.read('CODE').mapValues({ table: { '1': 'active', '2': 'inactive' } })
d.read('DATE').asIsoDateTime()
d.read('CODE').use('my-transform', 1) // custom transformCondition builders
d.present('Field') // { exists: { from: 'Field' } }
d.absent('Field') // { absent: { from: 'Field' } }
d.eq(d.read('F'), d.constant('x')) // { eq: [binding, binding] }
d.ne(d.read('F'), d.constant('x'))
d.oneOf(d.read('F'), ['a','b']) // { in: [...] }
d.notOneOf(d.read('F'), ['a','b']) // { nin: [...] }
d.matches(d.read('F'), '^\\d+$') // { matches: [binding, pattern] }
d.all(cond1, cond2) // { and: [...] }
d.any(cond1, cond2) // { or: [...] }
d.negate(cond) // { not: cond }Full mapping definition builder
import { dsl as d } from '@opencharts/mapper';
const mapping = d.defineMapping('patient-feed', 1, {
source: d.csv('epic'),
identity: d.keyBy('Patient', 'ehr', { mrn: d.read('MRN') }),
resources: [
d.resource('Patient', 'patient', {
identifiers: d.list(
d.record({ system: d.constant('mrn'), value: d.read('MRN').trim() })
),
birth_date: d.read('DOB').asIsoDate(),
gender: d.read('SEX').asGender(),
}).resolveBy('epic-patient', { mrn: d.read('MRN').trim() }),
],
});Using the Registry
import { PureMappingRegistry, PureResourceMapper } from '@opencharts/mapper';
const registry = new PureMappingRegistry();
registry.register(mapping);
// Look up by id
const def = registry.get('patient-feed');
// Auto-route by source type and system
const def2 = registry.findBySource('csv', 'epic');
const mapper = new PureResourceMapper({ normalizers });
const { resources } = mapper.map(def, sourceRecord);Opt-in Catalog Validation
import { validateMappedResources } from '@opencharts/mapper';
// resourceValidator from @opencharts/resources
const { valid, errors } = validateMappedResources(resources, resourceValidator);Identity Resolution (resolve directive)
A resource that needs identity resolution carries a resolve block. The mapper carries the inputs through verbatim for a downstream assembler:
{
key: 'patient',
type: 'Patient',
parameters: { identifiers: [...] },
resolve: {
profile: { id: 'epic-patient', version: 1 },
identity_parameters: { mrn: d.read('MRN').trim() },
},
}License
Why it exists
The legacy SourceMappingEngine conflates three jobs in one apply():
- transform — source record → canonical resources (the deterministic part);
- resolution planning — building
shared_resolutionsseeds that couple to theIdentityResolutionaggregate and stamping aresolution.status; - case assembly — packing
demand,workflow,business_key, provenance for anOperationCase.
That conflation is behind three concrete problems:
shared_resolutionsis not a canonical resource. It sits as a sibling array next tocase_resources, yet it is really "resolve the identity of a resolvable canonical resource" — an asymmetry.- The mapper materializes canonical resource instances to "seed the case
context." But the spec's target (
OperationCase.context.resources) does not exist on the entity — the case storesresource_parameters+ResourceRequirement[], and the real ingestion path (ClinicalOperationFileLoader) usesresource_parametersand never touchescase_resources. So the validated-instance path is largely vestigial. - It isn't a pure mapper. Jobs (2) and (3) are side concerns bolted onto a transform.
What this mapper does (and only this)
PureResourceMapper.map(mapping, record) → { resources, business_key? }
Deterministic & pure: same
(mapping, record)→ same output. No I/O, no clock, no random, no global state. The only dependency is an optional injected normalizer registry (pure functions) used for a binding'stransforms.No coupling to
IdentityResolution,OperationCase,demand, orworkflow.Everything is a canonical resource. There is no
shared_resolutions. A resolvable resource carries an inlineresolvedirective:{ key: 'patient', type: 'Patient', parameters: { identifiers: { from: 'MRN' } }, resolve: { // resolution INPUTS, as data profile: { id: 'epic-patient', version: 1 }, identity_parameters: { mrn: { from: 'MRN' } }, }, }The mapper resolves the
identity_parametersfrom the record and carries the block through verbatim — it never performs resolution or sets aresolution.status. A downstream assembler feedsresolveto the identity subsystem and builds the case.Optional catalog validation is separate (
validateMappedResources), somap()stays pure. Wrap the output with aCanonicalResourceValidatoronly when you want a catalog check.
Usage
import { PureResourceMapper } from 'opencharts/puremapper/index.js';
import { createCanonicalSystem } from 'opencharts';
const { normalizers, resourceValidator } = createCanonicalSystem();
const mapper = new PureResourceMapper({ normalizers }); // normalizers optional
const { resources, business_key } = mapper.map(
{
id: 'csv-mini', version: 1,
source: { type: 'csv', system: 'demo' },
business_key: { namespace: 'demo', type: 'Patient',
components: { mrn: { from: 'MRN', transforms: [{ name: 'trim', version: 1 }] } } },
resources: [{
key: 'patient', type: 'Patient',
parameters: { identifiers: { array: [ { object: { system: { literal: 'mrn' }, value: { from: 'MRN' } } } ] } },
resolve: { profile: { id: 'epic-patient', version: 1 }, identity_parameters: { mrn: { from: 'MRN' } } },
}],
},
{ MRN: ' MRN-1 ' },
);
// opt-in catalog validation (separate from the pure transform):
import { validateMappedResources } from 'opencharts/puremapper/index.js';
const report = validateMappedResources(resources, resourceValidator); // { valid, errors }Open question (deferred)
Whether this pure mapper or the imperative ClinicalOperationMapper (the one the
file loader actually uses) becomes THE canonical mapper is not decided yet. This
folder exists so the pure approach can be built and evaluated without disturbing the
legacy path. When we decide, the loser is retired and the winner absorbs the
remaining concerns (assembly, resolution planning) as separate composable steps.
