Documentation / Développement / Référence / Binding map expressions (MapExpr)

Binding map expressions (MapExpr)

MapExpr is the projection grammar an event binding uses to turn one field of a raw record (a FHIR resource, an HL7 message, any JSON-shaped payload from an event source) into one field of a domain event, or into one of the run / decision / by values used to resolve a suspended human validation. It is pure, declarative data — no code, no URL — so a binding travels between plugin and instance, and between instances, unchanged.

The grammar is defined once, browser-safe, in libs/shared/src/plugin/mapping.ts as the zod schema MapExprSchema (exported type MapExpr). It is consumed by EventBindingContribSchema in libs/shared/src/plugin/manifest.ts. Evaluation is a separate concern that lives in libs/engine-core/src/engine/event-mapping.ts (applyMapping, evalExpr), called by the API host — the schema never imports an evaluator, and the evaluator never imports zod.

This page documents the grammar and its evaluation semantics exhaustively. For the surrounding binding schema (event, resolve, query…) see the plugin manifest reference; for the authoring workflow see Contribute an event source and binding.

Where a MapExpr is used

Location Shape Required
eventBindings[].map Record<string, MapExpr> no (defaults to {})
eventBindings[].resolve.run MapExpr yes, when resolve is present
eventBindings[].resolve.pending MapExpr no
eventBindings[].resolve.decision MapExpr yes, when resolve is present
eventBindings[].resolve.by MapExpr no

A binding declares exactly one of event (with map) or resolve — see Event bindings.

Grammar

MapExprSchema is a zod union of four forms, checked in this order:

Form Zod shape Meaning
Dotted path (shorthand) z.string() Property access by .-separated segments; a numeric segment indexes an array.
Path object z.object({ path: z.string(), ref: z.boolean().optional() }) Same access as the string form, plus optional FHIR-reference stripping.
JSONPath object z.object({ jsonpath: z.string(), all: z.boolean().optional(), ref: z.boolean().optional() }) A jsonpath-plus expression (filters, wildcards, recursive descent).
Literal z.object({ const: z.unknown() }) A fixed value, independent of the record.

Source: MapExprSchema in mapping.ts.

Dotted path — "a.b.0.c"

The shorthand form. Evaluated by getPath:

function getPath(obj: unknown, path: string): unknown {
  let cur: unknown = obj;
  for (const seg of path.split(".")) {
    if (cur == null) return undefined;
    cur = (cur as Record<string, unknown>)[seg];
  }
  return cur;
}
  • The path is split on .; each segment is a plain property lookup.
  • A numeric segment (e.g. "0") indexes an array the same way — array elements have no separate syntax.
  • If any intermediate value is null or undefined, evaluation short-circuits and returns undefined; getPath never throws.

Example (from external-plugins/fhir/plugin.yaml's fhir-creatinine binding): value: "valueQuantity.value", occurredAt: "effectiveDateTime".

Path object — { path, ref?: true }

Same traversal as the string form (getPath(record, expr.path)), packaged so ref can be attached.

Key Type Required Meaning
path string yes Dotted path, identical semantics to the shorthand.
ref boolean no When true, strip a FHIR-style reference prefix from a string result.

ref calls stripRef:

function stripRef(v: unknown): unknown {
  return typeof v === "string" && v.includes("/") ? v.slice(v.indexOf("/") + 1) : v;
}

It takes the substring after the first / — not specifically the FHIR ResourceType/id pattern, any string containing a / is cut there ("Patient/patient-42" → "patient-42"; "a/b/c" → "b/c"). A non-string value, or a string without /, passes through unchanged. ref is silently a no-op when the underlying value isn't a string.

Example: patientId: { path: subject.reference, ref: true } on { subject: { reference: "Patient/patient-42" } } → "patient-42".

JSONPath object — { jsonpath, all?: true, ref?: true }

For cases the dotted path can't express: filters, wildcards, recursive descent. Evaluated with the jsonpath-plus library:

function evalJsonPath(record: unknown, path: string, all?: boolean): unknown {
  const res = JSONPath({ path, json: record as object, wrap: !!all });
  if (all) return res; // always an array
  return Array.isArray(res) ? res[0] : res; // filter → array: take the first
}
Key Type Required Meaning
jsonpath string yes A JSONPath expression, evaluated against the raw record as $.
all boolean no false/absent: return only the first match. true: return every match, always as an array (wrap: true).
ref boolean no Same stripRef post-processing as the path object — applied to whatever evalJsonPath returns.

With all absent (wrap: false, jsonpath-plus's own semantics): zero matches → undefined; exactly one match → that value, unwrapped; more than one match → an array (of which only the code path documented above still takes index 0 — effectively "first match" in every case).

A malformed JSONPath expression, or an expression evaluated against a record shape it doesn't expect, can throw — see Errors and omission.

Examples (from apps/api/tests/test-events.ts), evaluated against { code: { coding: [{ system: "...snomed...", code: "X" }, { system: "http://loinc.org", code: "2160-0" }] }, identifier: [{ value: "lab-1" }, { value: "lab-2" }], subject: { reference: "Patient/p9" } }:

Expression Result
{ jsonpath: "$.code.coding[?(@.system=='http://loinc.org')].code" } "2160-0"
{ jsonpath: "$.subject.reference", ref: true } "p9"
{ jsonpath: "$.identifier[*].value", all: true } ["lab-1", "lab-2"]

Edge case — ref with all: true: stripRef only acts on a string value; with all: true the result is an array, so ref has no effect on it (each element is not stripped individually). Combining ref: true with all: true is therefore meaningless as currently implemented.

Literal — { const: <any> }

if ("const" in expr) return expr.const;

Returns the configured value verbatim, ignoring the record entirely. const is z.unknown(): any JSON-compatible value (string, number, boolean, object, array, null) is accepted, not only strings.

Example: analyte: { const: creatinine }.

Evaluation — evalExpr and applyMapping

Dispatch, in evalExpr (event-mapping.ts):

Check Branch
typeof expr === "string" Dotted path via getPath.
"const" in expr Literal.
"jsonpath" in expr JSONPath via evalJsonPath, then ref if set.
(else) Path object: getPath(record, expr.path), then ref if set.

A whole mapping table is applied with applyMapping:

export function applyMapping(record: unknown, map: Record<string, MapExpr>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [field, expr] of Object.entries(map)) {
    const v = evalExpr(record, expr);
    if (v !== undefined) out[field] = v;
  }
  return out;
}
  • Every field: MapExpr entry in map (or in resolve) is evaluated independently against the same raw record.
  • A result of undefined omits the key from the output object — it is never present with the value undefined. A partial raw record therefore yields a partial event, not one full of undefined fields.

Errors and omission

getPath and the literal/path branches never throw for an expression of one of the forms above. evalJsonPath (via jsonpath-plus) can throw — a malformed JSONPath string, or an expression applied to a record shape it cannot traverse. applyMapping itself does not catch that: the throw propagates to the caller.

An object that matches none of the forms still passes the schema: the literal form { const } accepts any object and keeps none of its other keys, so the expression is read as {}. That happens with a misspelled key ({ jsonpat: "$.x" }) and with a value of the wrong type ({ path: subject.reference, ref: "yes" }, { jsonpath: 1 }). The plugin load reports each key of such an expression as unknown (kind key), the valid ones included, and evalExpr then reaches the path branch without a path and throws, so every record of the binding is rejected. Check the key names and the types of the values.

The host (apps/api/src/server/source-records.ts, called when the reception delivers a source record) wraps every applyMapping call in a try/catch per record:

  • On the event/map path, a throw is logged and the generation is recorded with outcome rejected; the record that failed to map does not stop the source or affect other records.
  • On the resolve path, a throw is likewise logged and recorded as rejected; an empty/missing run value (m.run falsy after mapping) is also treated as rejected without calling the resolution path, as is an empty/missing pending value when the binding declares pending.

See apps/api/src/server/source-records.ts (createRecordProcessor). A throw from the rest of the processing (the ingress unreachable while starting or resuming a run) is not a mapping failure: the reception delivers the record again — see Records that fail to process.

Host application

Starting a run (event + map)

The host builds the domain event object as:

event = {
  ...applyMapping(data, binding.map),
  type: binding.event,
  eventId: `${connectionId}:${feed}:${recordId}`,
};

eventId is derived from the identifier of the inbound connection that received the record, the feed (= binding) name, and the record id the source mechanism supplied — stable across re-emissions of the same logical record, which is what makes ingestion idempotent downstream. The mapped fields never include type or eventId themselves; those two are added after applyMapping runs, so a map entry named type or eventId is overwritten. For a declared event, such a key is also reported at load (« absent du contrat »), as are connectionId, recordId and names starting with __: none of them is a contract key. occurredAt is a contract key, typed DateTime; mapped, it replaces the reception time the ingestion sets otherwise.

Values are not converted: the value the expression reads is the value the event carries. A Decimal field of a declared event needs a JSON number in the record, not numeric text.

Next, the binding's require is applied to the projected event: when one of the listed fields is absent — missing, null, a string empty once trimmed, or [] for a field the contract declares as a list — no event is emitted, the generation is counted rejected, and the host logs at debug level « enregistrement filtré (champs exigés vides) ». Under EVENT_CONTRACTS=enforce, the contract's required fields and required-kind ids are added to the list.

The resulting outcome, recorded per source/feed/event, is one of accepted (at least one run started), duplicate (every workflow that receives the event already had it), filtered (workflows receive the type, none of their filters keeps the event), rejected (mapping threw, a require field is empty, or the ingestion rejected the event — an unclaimed type, or a type no published workflow receives), or unavailable (a run could not be submitted to Restate; the reception delivers the record again and the retry starts only the missing runs).

Resolving a validation (resolve)

const m = applyMapping(data, binding.resolve);
// resolveFromBinding (apps/api/src/server/validation-resolve.ts)
const runId = m.run != null ? String(m.run) : "";
// … opResolve(runId, { decision: …, by: …, pendingId: m.pending != null ? String(m.pending) : undefined })

m.run, m.pending, m.decision, m.by are read back as plain values (coerced with String(...) when present) and passed to the host's run-resolution path. The decision is normalized there: any value other than the literal string "rejected" is treated as "accepted" — so, for example, a FHIR Task status of completed maps to an accepted decision. With pending, the decision is applied only if the run waits on that validation. Resolution is idempotent: re-observing an already-applied decision is a benign no-op (duplicate outcome), not an error.

See Add human validation and Run and resolve for the run-side lifecycle that resolve bindings feed into.

Worked example

The fhir-creatinine binding's map (see Contribute an event source and binding), applied to a raw Observation:

map:
  patientId:   { path: subject.reference, ref: true }
  encounterId: { path: encounter.reference, ref: true }
  analyte:     { const: creatinine }
  value:       valueQuantity.value
  unit:        valueQuantity.unit
  occurredAt:  effectiveDateTime
{
  "subject": { "reference": "Patient/patient-42" },
  "encounter": { "reference": "Encounter/enc-7" },
  "valueQuantity": { "value": 160, "unit": "µmol/L" },
  "effectiveDateTime": "2026-06-10T09:30:00Z"
}

produces:

{
  "patientId": "patient-42",
  "encounterId": "enc-7",
  "analyte": "creatinine",
  "value": 160,
  "unit": "µmol/L",
  "occurredAt": "2026-06-10T09:30:00Z"
}

If the raw record only has subject.reference (no valueQuantity, no effectiveDateTime), the produced object is { "patientId": "patient-42", "analyte": "creatinine" } — the other keys are omitted, not set to null or undefined.

The corresponding resolve variant (fhir-validation-decided, on the FHIR Task the platform publishes for a pending validation):

resolve:
  run:      { jsonpath: "$.identifier[?(@.system=='urn:ehr-lab:validation-run')].value" }
  pending:  { jsonpath: "$.identifier[?(@.system=='urn:ehr-lab:validation-pending')].value" }
  decision: status
  by:       { path: owner.reference, ref: true }

run reads the identifier that carries the run id. A domain run id (<eventId>:<slug>) contains :, so it is stored in an identifier value, not in a resource id. pending reads the identifier that carries the decided validation (pending.id of the run).

The JSONPath examples of the JSONPath object section are exercised as executable assertions in apps/api/tests/test-events.ts.

97 documents13 sectionssource : /docs · généré au build