Contribute an event source and binding
This recipe adds event-driven ingestion to a plugin: a source mechanism that reads from an external system (a polled FHIR server, an HL7 socket…) and a declarative binding that projects each raw record into a domain event that starts a workflow. You author both; an operator later supplies only the connection config on their instance.
Ingestion is split into three independently contributable concerns
(libs/shared/src/plugin/manifest.ts):
eventSources— a generic mechanism. It takes a connection config plus a list of opaque feeds and emits raw records. It knows nothing about domain events.eventBindings— the declarative, reusable link:source+query+event+map. This is where a raw record becomes a domain event.- The instance — supplies the connection config and an optional allow-list. That side is the operator's; see Connect an event source and activate bindings.
For the why behind this seam, read The open event model.
Before you start
- A plugin scaffold you can edit (see Scaffold a plugin).
- The domain event type you intend to emit must be one that a workflow
claims as its trigger — a node of
kind: triggerdeclares itseventType(see the plugin manifest reference). The binding'seventmust match that string exactly, or the emitted event is rejected at ingest. - Familiarity with the projection grammar (MapExpr reference).
1. Write the source mechanism
Add a module that default-exports a SourceFactory
(@meridian/shared/plugin/source). The factory receives { config, feeds, emit, log } and returns a SourceHandle with start() / stop():
import type { SourceFactory, SourceFeed } from "@meridian/shared/plugin/source";
const fhirPoll: SourceFactory = ({ config, feeds, emit, log }) => {
const intervalMs = Number(config.intervalSeconds ?? 15) * 1000;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async () => {
for (const feed of feeds) {
// feed.query is the OPAQUE query descriptor authored by the binding.
const records = await pollOnce(config, feed.query);
for (const rec of records) {
// emit(feedName, stable recordId, rawData)
await emit(feed.name, rec.stableId, rec.raw);
}
}
timer = setTimeout(() => void tick(), intervalMs);
};
return {
start() { log(`polling every ${intervalMs / 1000}s`); void tick(); },
stop() { if (timer) clearTimeout(timer); },
};
};
export default fhirPoll;
Hold to the contract:
configis the connection config from the instance manifest (base URL, interval…). Never hard-code an endpoint here.feedsis oneSourceFeed({ name, query }) per active binding on this mechanism. Eachqueryis a record you defined in the binding; the mechanism interprets it however it likes.emit(feed, recordId, data)hands a raw record to the engine. TherecordIdmust be stable for the same logical record — the engine derives the event's idempotency key from it (eventId = source:feed:recordId,apps/api/src/server/api.ts), so a resent record does not start a duplicate run. Choose whether an update re-emits (a newrecordId) or not, per your mechanism's semantics.- The mechanism does no mapping to domain events — that is the binding's job (step 2).
The reference fhir-poll mechanism lives at
external-plugins/fhir/sources/poll.ts if you want a full example (per-feed
_lastUpdated cursor, dedupe/since conventions read off feed.query).
Register the module in plugin.yaml:
contributes:
eventSources:
- name: fhir-poll
module: ./sources/poll.ts
description: Polls a FHIR server and emits raw resources.
export defaults to default; set it if you export the factory under another
name (libs/shared/src/plugin/manifest.ts).
2. Declare a binding that starts a run
A binding names a feed (name), targets a mechanism (source),
carries the opaque query the mechanism will run, and projects the raw
record into a domain event via map:
contributes:
eventBindings:
- name: fhir-creatinine # = the feed name handed to the mechanism
source: fhir-poll
event: BioResultReceived # must match a trigger node's eventType
query: { resource: Observation, params: { code: "http://loinc.org|2160-0" } }
map:
patientId: { path: subject.reference, ref: true } # "Patient/x" → "x"
analyte: { const: creatinine } # literal
value: valueQuantity.value # dotted path (shorthand)
loincCode: { jsonpath: "$.code.coding[?(@.system=='http://loinc.org')].code" }
nameis the feed name: it is what appears asfeed.namein your mechanism and what an operator allow-lists. Keep it unique and descriptive.queryis free-form (defaults to{}). Its shape is a private contract between this binding and the mechanism it targets — the FHIR poller readsresource/params, another mechanism would read something else.eventmust equal theeventTypea trigger node declares, otherwiseingestEventrejects it.mapis a record of field name →MapExpr. Each value is evaluated against the raw record; the results plustype(=event) and a generatedeventIdbecome the domain event. Amapthat throws counts as a rejected generation and does not crash the source.
The MapExpr grammar (full detail and edge cases in the
MapExpr reference):
| Form | Meaning |
|---|---|
"a.b.0.c" |
dotted path (shorthand) |
{ path, ref: true } |
dotted path, then strip a FHIR reference ("Patient/x" → "x") |
{ jsonpath, all?: true } |
JSONPath (filters, wildcards); first match unless all |
{ const } |
literal value |
The grammar is declarative and code-free (contract in
libs/shared/src/plugin/mapping.ts; evaluation in
apps/api/src/engine/event-mapping.ts), so a binding travels between instances
unchanged. For mapping FHIR resources specifically, see the
FHIR mapping reference (apps/ehr-lab/docs/resources/reference/fhir-mapping.md).
3. Variant — resolve a validation instead of starting a run
A binding may instead observe an external decision and use it to resume a
suspended run (a human validation completed in another system) rather than
start a new one. Replace event + map with a resolve block. A binding
must declare exactly one of event or resolve
(libs/shared/src/plugin/manifest.ts):
contributes:
eventBindings:
- name: fhir-task-decisions
source: fhir-poll
query: { resource: Task, params: { status: "completed,rejected" }, since: epoch }
resolve:
run: { jsonpath: "$.basedOn[0].reference", ref: true } # the run key
decision: status # accepted|rejected
by: { path: owner.reference, ref: true } # optional author
run, decision, and optional by are each a MapExpr. On each emit the
engine maps them and calls the host's resolution path (opResolve), which is
idempotent — re-observing a decision that was already applied is a benign
no-op. decision is normalised: anything other than rejected is treated as
accepted. This branch carries no FHIR-specific notion; it is the generic
"resume a durable validation" seam. See
Add human validation and
Run and resolve for the run-side
of that lifecycle.
4. Build and ship
Bundle the plugin with the CLI (esbuild):
meridian-plugin build
This compiles your source module and behaviors into dist/; an external plugin
is loaded from there. Details in
Build and bundle. To distribute it, publish to a
registry — see Publish to the registry.
5. Hand off to the operator
You do not configure an endpoint. Document, for the operator, the source
name, each binding name, and what connection keys your mechanism reads from
config. On their instance manifest they add the connection under sources:
(keyed by source name) and optionally allow-list bindings:
sources:
fhir-poll: { baseUrl: http://fhir.internal/fhir, intervalSeconds: 15 }
# eventBindings: [fhir-creatinine] # optional allow-list; omit = all contributed bindings fire
The rest of the operator recipe — activation rules, verifying feeds fire — is Connect an event source and activate bindings; the manifest field reference is the instance manifest.
Related
- The open event model — why sources, bindings, and connection are three separate concerns.
- MapExpr reference — the projection grammar in full.
- SDK surface — the
@meridian/sharedimports a plugin compiles against. - Plugin manifest reference —
contributes.eventSources/contributes.eventBindingsschemas.