Documentation / Développement / Guides pratiques / Contribute an event source and binding

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: trigger declares its eventType (see the plugin manifest reference). The binding's event must 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:

  • config is the connection config from the instance manifest (base URL, interval…). Never hard-code an endpoint here.
  • feeds is one SourceFeed ({ name, query }) per active binding on this mechanism. Each query is 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. The recordId must 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 new recordId) 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" }
  • name is the feed name: it is what appears as feed.name in your mechanism and what an operator allow-lists. Keep it unique and descriptive.
  • query is free-form (defaults to {}). Its shape is a private contract between this binding and the mechanism it targets — the FHIR poller reads resource/params, another mechanism would read something else.
  • event must equal the eventType a trigger node declares, otherwise ingestEvent rejects it.
  • map is a record of field name → MapExpr. Each value is evaluated against the raw record; the results plus type (= event) and a generated eventId become the domain event. A map that 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.

75 documents7 sectionssource : /docs · généré au build