npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@incoqnito.io/wflib

v1.0.0

Published

`@incoqnito.io/wflib` ist eine TypeScript-Bibliothek für Workflow-/Prozessmodellierung und -ausführung im Stil von BPMN 2.0 — Typmodell, struktureller Validator und eine Ausführungs-Engine.

Readme

wflib

@incoqnito.io/wflib ist eine TypeScript-Bibliothek für Workflow-/Prozessmodellierung und -ausführung im Stil von BPMN 2.0 — Typmodell, struktureller Validator und eine Ausführungs-Engine.

Dieses Dokument beschreibt Funktionalität und Architektur. Begründungen, Alternativen und Hintergründe stehen in README_ext.md.

Umfang

wflib liefert vier Bausteine:

  • Typmodell (workflowModel.ts) — Knoten, Kanten, Gateways, Events, Tokens, Instanzen.
  • Validator (validator.ts) — prüft eine Definition strukturell, einmalig vor der Ausführung.
  • Token-Strategien (tokens.ts) — austauschbare Policy für Token-Konstruktion/-Übergänge.
  • Ausführungs-Engine (engine.ts, Typmodell dazu in engineModel.ts) — führt eine validierte Definition tatsächlich aus, gegen austauschbare Infrastruktur-Ports (adapters.ts).

Dazu zwei optionale, komplett entkoppelte weitere Bausteine: der Timer Scheduler (timerScheduler.ts, siehe eigener Abschnitt unten) — für Consumer, die TIMER-Events tatsächlich scharfstellen wollen, statt onTimerFired von Hand zu rufen — und der BPMN-Export (bpmnExport.ts, siehe eigener Abschnitt unten) — für Consumer, die eine Definition als BPMN-2.0-XML (z. B. für bpmn-js/Camunda Modeler) exportieren wollen.

Anforderungen

  • Node.js ^24.5.0 (siehe package.json, engines) — die Bibliothek nutzt neuere Node-APIs (u. a. structuredClone, node:timers/promises), die auf älteren Runtimes nicht laufen.
  • Reines ESM ("type": "module" in package.json); ein Consumer mit CommonJS-Build braucht ein entsprechendes Interop-Setup.
  • Lizenz/Repository: siehe package.json (license, repository, homepage).

Grundbegriffe

  • IWorkflowDefinitionDraft — das rohe, noch nicht validierte Austauschformat: eine Adjazenzliste aus Knoten (nodes, per id indiziert) und Kanten (edges). Wird von Client, Server und Validator-Eingabe gleichermaßen verwendet.
  • IWorkflowDefinition — die validierte Form: ein IWorkflowDefinitionDraft plus derived: IWorkflowDefinitionDerivedData, einmalig von validateWorkflowDefinition berechnet. Einmal veröffentlicht als unveränderlich behandelt. Das, womit IWorkflowDefinitionRegistry und die Engine arbeiten. (Warum die Namen so herum vergeben sind: README_ext.md, "IWorkflowDefinitionDraft / IWorkflowDefinition — die Namensvertauschung".)
  • IWorkflowDefinitionDerivedData — von der Validierung einmalig berechnete Zusatzdaten. Aktuell ein Feld: loopBackEdgeIDsBySplitNodeID: Record<string, string> — pro schleifenschließendem IGatewayXORSplitNode die Kante, die die Schleife tatsächlich schließt.
  • IWorkflowInstance — der laufende Zustand eines gestarteten Prozesses: state (EWorkflowState), context (fachliche Prozessvariablen), tokens (alle ITokens der Instanz, aktiv und inaktiv, per id indiziert), engineData (opake, JSON-serialisierbare Ablage für Engine-internes Bookkeeping), externalData (opake, JSON-serialisierbare, per IToken.id indizierte Ablage für das, was ein IEventNodeLifecycleHook bei onEventNodeEntered zurückgibt — anders als engineData KEIN Engine-internes Bookkeeping, die Engine merged/entfernt nur mechanisch, siehe "Event-Knoten-Lifecycle-Hook" unten), error (gesetzt bei FAILED), version (Optimistic-Locking-Zähler). Rein serialisierbare Daten, keine Methoden.
  • IToken — ein einzelner Ausführungsfaden innerhalb einer Instanz: positionNodeID, active, parentTokenID (Abstammung), siblingTokenIDs (Geschwister aus demselben Fan-out, derselben Boundary-Event-Gruppe, oder einer Discriminator-/Race-Situation — welcher Fall vorliegt, bestimmt die Engine strukturell, siehe "Engine" unten).

Jeder Knotentyp trägt einen Diskriminator-Wert (nodeType, bei Gateways zusätzlich gatewayType/mode, bei Events eventType), über den TypeScript die jeweilige Union (TWorkflowNode, TGatewayNode, TEventNode) narrowt.

Drei Ebenen von Feldern

Auf jedem Node-Interface werden drei Feld-Kategorien unterschieden:

  1. Diskriminatoren (nodeType, gatewayType, mode, eventType) — sitzen immer auf oberster Ebene.
  2. Graph-Querverweise — reine ID-Referenzen auf andere Knoten (IGatewayORSplitNode.joinNodeID/IGatewayORJoinNode.splitNodeID, IGatewayANDSplitNode.joinNodeID/IGatewayANDJoinNode.splitNodeID, IEventNode.attachedToNodeID). Sitzen immer auf oberster Ebene, nie in einer Config.
  3. Fachliche Eigenwerte (messageName, errorCode, timerCycle, triggerType, taskType, …) — leben in einem dedizierten I<Type>Config-Interface, angebunden über IConfigurable<TConfig> (config?: TConfig bzw. config: TConfig, wenn zwingend erforderlich).
  4. Beschriftung (name, description) — über IDescribed gemixt in IWorkflowNode, IWorkflowEdge und IWorkflowDefinitionDraft. Rein deskriptiv, von der Engine nie gelesen; beide Felder optional.

IWorkflowEdge selbst hat kein Config-Objekt: condition (geparstes JSONLogic), isDefault, isLoopEscalation, name/description (IDescribed) sitzen flach auf IWorkflowEdge.

Gateways

Ein Gateway wird durch die Kombination aus gatewayType und mode (SPLIT/JOIN) bestimmt:

| Typ | Split | Join | |---|---|---| | XOR | genau ein ausgehender Pfad (erste passende condition, sonst isDefault) | lässt jedes ankommende Token sofort durch | | AND | alle ausgehenden Pfade unbedingt (Fork), über joinNodeID mit einem AND-Join gekoppelt | wartet auf ein Token auf jeder eingehenden Kante, über splitNodeID mit seinem AND-Split gekoppelt | | OR | ein oder mehrere ausgehende Pfade je nach condition (BPMN "Inclusive Gateway"), über joinNodeID mit einem OR-Join gekoppelt | wartet nur auf die am gekoppelten Split tatsächlich aktivierten Zweige | | EVENT | Race zwischen den nachfolgenden Event-Knoten, wer zuerst feuert gewinnt | kein Join-Gegenstück | | DISCRIMINATOR | kein Split-Gegenstück (aber optional als originSplitNodeID referenzierbar, s. u.) | lässt das erste ankommende Token durch, cancelt den Rest |

AND-/OR-Split/-Join-Paare referenzieren sich gegenseitig über joinNodeID/splitNodeID. validator.ts prüft Existenz, Typ, Gegenseitigkeit sowie die Single-Entry/Single-Exit-Eigenschaft des Paares. (Theoretischer Hintergrund zu OR-Join vs. Discriminator, und warum EVENT kein Join-Gegenstück hat: README_ext.md, "Gateways — OR-Join vs. Discriminator".)

Discriminator — optionaler originSplitNodeID: Ein Discriminator kann optional per originSplitNodeID einen EGatewayType.EVENT-Split benennen, von dem alle seine eingehenden Zweige stammen. Anders als bei AND/OR ist das eine EINSEITIGE Referenz — der EVENT-Split trägt kein Gegenstück-Feld zurück, validator.ts bestätigt die Paarung nur von der Discriminator-Seite aus. Ist sie gesetzt, prüft validator.ts dieselbe Single-Entry/Single-Exit-Eigenschaft wie bei AND/OR (alle Zweige des Splits müssen ausschließlich über diesen Discriminator enden; der Discriminator darf nur über diesen Split erreichbar sein) und berechnet dieselbe Region — ein Discriminator/Origin-Paar darf dann, wie ein AND-/OR-Paar, von einer Schleife vollständig umschlossen sein. Nur EVENT-Splits kommen als Origin infrage: XOR-Splits forken nie (nichts zu diskriminieren), AND-/OR-Splits sind bereits zwingend mit ihrem eigenen Join gepaart und können nicht stattdessen in einen fremden Discriminator münden. Ohne originSplitNodeID bleibt das bisherige Verhalten (kein Schleifenpfad erlaubt) unverändert. (Design-Hintergrund: README_ext.md, "Discriminator — optionaler Ursprungs-Split".)

Engine-Laufzeitverhalten:

  • AND-/OR-Join-Merge: Sobald die Kohorte vollständig ist (AND: alle eingehenden Kanten; OR: genau die vom Split aktivierten Zweige, siehe unten), entscheidet ITokenStrategy.merge(survivor, retiredSiblingIDs), welches Token weiterläuft; die übrigen Kohorten-Mitglieder werden über ITokenStrategy.cancel retiriert. Welche aktuell geparkten Tokens zur Kohorte gehören, wird strukturell über die parentTokenID-Kette bis zum passenden Split (node.splitNodeID) ermittelt (findArrivedForkCohort/findForkOriginTokenID, engine.ts) — NICHT über token.siblingTokenIDs, das bei NewTokenPerIterationStrategy mit jeder Loop-Iteration innerhalb eines Zweigs verloren geht (siehe README_ext.md, "Deadlock bei einem Loop innerhalb eines AND-/OR-Split-Zweigs").
  • OR-Split/-Join (Latch-Transport): Beim Feuern schreibt der OR-Split die IDs der tatsächlich aktivierten Kanten nach instance.engineData.orLatches[splitNodeID]. Der gekoppelte OR-Join liest diese Liste über node.splitNodeID als Kohorten-Erwartung, statt einer statischen Kantenzahl, und löscht den Eintrag nach dem Merge wieder.
  • EVENT-Split-Race: Sobald ein Zweig feuert (onMessageReceived/onSignalReceived/onTimerFired), werden die übrigen, noch aktiven Renn-Geschwister über ITokenStrategy.cancel abgebrochen.
  • Discriminator-Join: Beim ersten Ankommen wird die Geschwister-Kohorte abgebrochen, unabhängig davon, wo sie sich gerade befinden. Ohne originSplitNodeID (Standardfall) direkt über token.siblingTokenIDs. Mit gesetztem originSplitNodeID stattdessen strukturell über die parentTokenID-Kette bis zu diesem Split ermittelt (analog findArrivedForkCohort, aber ohne dessen Einschränkung auf am selben Knoten geparkte Tokens) — nötig, weil token.siblingTokenIDs bei NewTokenPerIterationStrategy mit jeder Loop-Iteration eines Zweigs verloren geht (siehe README_ext.md, "Deadlock bei einem Loop innerhalb eines AND-/OR-Split-Zweigs", und "Discriminator — optionaler Ursprungs-Split" für diesen Fall).

Events

IEventNode (eventType) deckt fünf Fälle ab:

| eventType | Config | Bedeutung | |---|---|---| | MESSAGE | IMessageEventConfig { messageName, correlationKey?, resultVariable? } | wartet auf (oder emittiert) eine benannte, adressierte Nachricht | | SIGNAL | ISignalEventConfig { signalName, resultVariable? } | wartet auf (oder emittiert) ein benanntes, per Broadcast verteiltes Signal — kein correlationKey, s. u. | | TIMER | ITimerEventConfig { timerDate?, timerDuration?, timerCycle?, resultVariable? } | fixer Zeitpunkt, relative Dauer, oder wiederkehrendes Intervall — genau eines der drei | | ERROR | IErrorEventConfig { errorCode, errorMessage? } | ein benannter, fachlicher Fehler ist aufgetreten | | ESCALATION | IEscalationEventConfig { escalationCode, escalationMessage? } | jemand soll informiert werden |

(Warum ESCALATION trotz struktureller Identität zu ERROR ein eigener Typ ist: README_ext.md, "Events — ESCALATION als eigener Typ".)

MESSAGE vs. SIGNAL: strukturell fast identisch — der Unterschied ist, dass die Engine für keines der beiden selbst adressiert oder zustellt. Auch onMessageReceived(instanceID, tokenID, payload) verlangt vom Aufrufer, instanceID/tokenID bereits zu kennen; correlationKey ist reine Consumer-seitige Dokumentation, nie von der Engine interpretiert. SIGNAL hat deshalb bewusst kein correlationKey-Feld — "Broadcast" ist hier rein eine Frage, wie oft ein Consumer onSignalReceived für unterschiedliche (instanceID, tokenID)-Paare aufruft, kein eigener Engine-Mechanismus. (Hintergrund zur Entscheidung: README_ext.md, "SIGNAL-Event — Design-Entscheidung".)

resultVariable (optional, MESSAGE/SIGNAL/TIMER): der Context-Key, unter dem die Engine den empfangenen Payload (MESSAGE/SIGNAL) bzw. den Feuerzeitpunkt als ISO-String (TIMER, da ein Timer keine eigene Payload trägt) ablegt. Bleibt es unbesetzt, wird nichts geschrieben.

Platzierung: Inline vs. Boundary

Ein IEventNode sitzt standardmäßig inline im normalen Sequenzfluss (attachedToNodeID weggelassen, BPMN "Intermediate Event"): das Token kommt an, das Event feuert, das Token läuft danach über die einzige ausgehende Kante weiter.

Optional wird ein IEventNode über attachedToNodeID an eine ITaskNode angeheftet (BPMN "Boundary Event"). Bleibt als eigenständiger Top-Level-Knoten bestehen; die ausgehende Kante startet am Event-Knoten selbst.

Engine-Laufzeitverhalten:

  • Beim Betreten eines Task-Knotens spawnt die Engine für jeden daran angehefteten IEventNode ein eigenes, aktives Watcher-Token, das am jeweiligen Event-Knoten parkt. Das Host-Token bleibt dabei aktiv und unverändert weiterlaufend (kein Fork im ITokenStrategy.fork-Sinn).
  • cancelActivity: true (interrupting): Feuert der Watcher, werden das Host-Token und alle anderen Watcher desselben Hosts abgebrochen; das feuernde Token läuft über seine eigene ausgehende Kante weiter.
  • cancelActivity: false/unbesetzt (non-interrupting): Feuert der Watcher, bleibt er selbst unverändert aktiv und geparkt (erneut feuerbar, z. B. bei einem wiederkehrenden timerCycle); ein eigenständiges, geschwisterloses Notification-Token läuft stattdessen über die ausgehende Kante weiter.
  • Schließt die Host-Task normal ab (onTaskCompleted), werden alle noch scharfen Watcher dieses Hosts abgebrochen.

(Warum non-interrupting Watcher nie konsumiert werden, statt einmalig zu feuern: README_ext.md, "Boundary Events — die Kurskorrektur bei wiederholtem Feuern".)

Tasks

ITaskNode trägt config: ITaskConfig { taskType, payloadVariables?, resultVariable? }.

  • taskType — an ITaskDispatcher.dispatch übergeben.
  • payloadVariables (optional) — Liste von Context-Keys; deren Werte werden als payload an dispatch übergeben (fehlt das Feld: leeres payload). dispatch erhält den vollständigen context unabhängig davon zusätzlich als eigenes Argument, neben instanceID/tokenID (siehe unten).
  • resultVariable (optional) — Context-Key, unter dem onTaskCompleteds result abgelegt wird. Unbesetzt: das Ergebnis wird verworfen.

ITaskDispatcher.dispatch(instanceID, tokenID, taskType, payload, context) bekommt instanceID/tokenID als direkte Parameter — symmetrisch zu ITimerScheduler.schedule (siehe "Timer Scheduler" unten). Ein Dispatcher, der die Fertigmeldung später über onTaskCompleted einliefert, hat damit beide IDs sofort zur Hand, ohne sie selbst über context korrelieren zu müssen.

(Rationale zu payloadVariables/resultVariable: README_ext.md, "Tasks — Payload-Mapping-Rationale".)

Schleifen

Eine Rückwärtskante ist nur an einem IGatewayXORSplitNode erlaubt. Ein AND- oder OR-Split/-Join-Paar darf von einer Schleife vollständig umschlossen sein (nie nur angeschnitten). Ereignisbasierte Splits und Discriminatoren sind auf jedem Schleifenpfad verboten — außer ein Discriminator benennt per originSplitNodeID seinen EVENT-Split-Ursprung (s. o., "Gateways"); dieses eine Paar darf dann, wie ein AND-/OR-Paar, von einer Schleife vollständig umschlossen sein. Ein unpaarer EVENT-Split oder Discriminator bleibt in jedem Fall verboten.

Verschachtelte Schleifen (eine Schleife vollständig innerhalb einer anderen) sind erlaubt — dieselbe "disjunkt oder sauber verschachtelt"-Regel wie bei AND-/OR-Gateway-Regionen gilt jetzt auch zwischen zwei Schleifen-Regionen (LOOPS_PARTIALLY_OVERLAP bei echter Überschneidung). Jede Schleife muss dabei genau einen Eintrittspunkt haben — ihren eigenen Header (LOOP_HAS_MULTIPLE_ENTRIES), und ein XOR-Split darf nie mehr als eine Schleife schließen (LOOP_SPLIT_CLOSES_MULTIPLE_LOOPS). Sich überlappende Schleifen (zwei Schleifen, die sich teilweise, aber nicht vollständig überschneiden) bleiben vorerst nicht unterstützt.

Iterationslimit (optional): IGatewayXORSplitNode.maxLoopIterations begrenzt, wie oft ein schließender Split zurückspringen darf. Ist das Limit gesetzt, verlangt der Validator genau eine ausgehende Kante mit isLoopEscalation: true — dorthin geht das Token, sobald das Limit erreicht ist.

(Warum das Limit am Split sitzt statt am Ziel-Knoten, und die Historie der AND-/OR-in-Schleifen-Unterstützung: README_ext.md, "Schleifen — Hintergründe".)

Engine-Laufzeitverhalten: Bei jedem Durchlauf eines maxLoopIterations-Splits schlägt die Engine in definition.derived.loopBackEdgeIDsBySplitNodeID[node.id] nach, welche Kante die Rückwärtskante ist. Nur wenn die gewählte Kante genau diese ist, zählt der Durchlauf als Iteration (instance.engineData.loopIterationCounts, pro Split-id) und wird über ITokenStrategy.nextIteration bewegt. Jede andere Kante (Vorwärts-Ausstieg) ist eine reine Weiterbewegung, ohne Zähler-Bookkeeping. Wird das Limit erreicht, geht das Token zwingend über die isLoopEscalation-Kante.

Bewusst nicht im Modell enthalten

  • Ein Versions-/Editionsfeld auf IWorkflowDefinition (IWorkflowInstance.version ist ein reiner Optimistic-Locking-Zähler, kein Definitions-Versionsfeld).
  • tenantID.

(Begründungen zu beiden Punkten: README_ext.md, "Bewusst nicht im Modell enthalten — Begründungen". EEventType.SIGNAL stand hier ursprünglich auch — seit 2026-08-19 Teil des Modells, siehe "Events" oben und README_ext.md, "SIGNAL-Event — Design-Entscheidung".)

Validator

validator.ts exportiert validateWorkflowDefinition(draft: IWorkflowDefinitionDraft): IValidationResult.

interface IValidationResult {
    readonly errors: IValidationError[];
    readonly result?: IWorkflowDefinition; // nur gesetzt, wenn errors leer ist
}

interface IValidationError {
    readonly code: EValidationErrorCode;
    readonly message: string;
    readonly nodeID?: string;
    readonly edgeID?: string;
}

Geprüft wird in dieser Reihenfolge:

  1. Referenzielle Integrität — mindestens ein Start- und ein End-Knoten (NO_START_NODE/NO_END_NODE); jeder Knoten hat als eigenes id-Feld genau den Key, unter dem er in nodes steht (NODE_ID_KEY_MISMATCH); keine dangling edges (DANGLING_EDGE_SOURCE/DANGLING_EDGE_DESTINATION); keine doppelt vergebene edge.id (DUPLICATE_EDGE_ID); jeder strukturelle Querverweis zeigt auf einen existierenden Knoten vom passenden Typ (CROSS_REFERENCE_NOT_FOUND/CROSS_REFERENCE_WRONG_TYPE); AND-/OR-Paare referenzieren sich gegenseitig (GATEWAY_PAIR_NOT_MUTUAL); ein gesetztes Discriminator-originSplitNodeID referenziert einen echten EVENT-Split (einseitig, keine Gegenseitigkeitsprüfung nötig — s. o., "Gateways"). Config-Shape-Prüfungen (siehe unten) laufen in derselben Runde mit.
  2. Vorwärts-Erreichbarkeit ab jedem Start-Knoten (UNREACHABLE_FROM_START) — ein Boundary-Event (attachedToNodeID gesetzt) gilt dabei implizit als erreichbar, sobald sein Host-Task erreichbar ist, auch ganz ohne eigene eingehende Kante (siehe "Platzierung: Inline vs. Boundary" oben).
  3. Schleifen-Legalität — schließt eine Schleife an einem IGatewayXORSplitNode (LOOP_BACK_EDGE_NOT_FROM_XOR_SPLIT); kein unpaares ereignisbasiertes/Discriminator-Gateway auf ihrem Pfad (LOOP_CONTAINS_UNSUPPORTED_GATEWAY — ein per originSplitNodeID bestätigtes Discriminator/EVENT-Split-Paar ist davon ausgenommen); jede Schleife hat genau einen Eintrittspunkt, ihren Header (LOOP_HAS_MULTIPLE_ENTRIES); ein Split schließt nie mehr als eine Schleife (LOOP_SPLIT_CLOSES_MULTIPLE_LOOPS); zwei Schleifen-Regionen sind entweder disjunkt oder sauber verschachtelt, nie nur angeschnitten (LOOPS_PARTIALLY_OVERLAP); keine Schleife schneidet eine Gateway-Region (AND/OR oder ein bestätigtes Discriminator-Paar) nur an (LOOP_PARTIALLY_OVERLAPS_GATEWAY_REGION); maxLoopIterations verlangt eine isLoopEscalation-Kante (LOOP_MAX_ITERATIONS_WITHOUT_ESCALATION_EDGE) und ist nur an einem tatsächlich schließenden Split zulässig (LOOP_MAX_ITERATIONS_ON_NON_LOOP_SPLIT).
  4. Rückwärts-Erreichbarkeit zu jedem End-Knoten (CANNOT_REACH_END).
  5. Default-Kante für jeden XOR-/OR-Split (SPLIT_WITHOUT_DEFAULT_EDGE).
  6. Single-Entry/Single-Exit — für bereits bestätigte AND-/OR-Paare UND für ein bestätigtes Discriminator/Origin-EVENT-Split-Paar (dieselben Prüfungen, wiederverwendet): GATEWAY_SPLIT_BYPASSES_JOIN, GATEWAY_JOIN_BYPASSED_BY_START, GATEWAY_REGIONS_OVERLAP.

Config-Shape-Prüfungen (MISSING_REQUIRED_CONFIG_FIELD für ein fehlendes Pflichtfeld — auch für den Fall "keines der Timer-Felder gesetzt" — bzw. CONFLICTING_TIMER_FIELDS speziell für "mehr als eines der einander ausschließenden Timer-Felder gesetzt"; ein leerer oder nur aus Leerzeichen bestehender String zählt als "fehlt"):

  • Start-Knoten: MANUAL braucht nichts; MESSAGE braucht messageName; SIGNAL braucht signalName; TIMER braucht genau eines von timerDate/timerCycle.
  • Event-Knoten: MESSAGE braucht messageName; SIGNAL braucht signalName; TIMER braucht genau eines von timerDate/timerDuration/timerCycle; ERROR braucht errorCode; ESCALATION braucht escalationCode.

Bei errors.length === 0 liefert result.derived.loopBackEdgeIDsBySplitNodeID für jeden schleifenschließenden Split die id seiner Rückwärtskante (leeres Objekt, wenn die Definition keine Schleife enthält).

Unit-Tests liegen unter src/tests/*.test.ts (Node-eigener Test-Runner, npm test).

(SESE-Approximation Soundness/Completeness, Grenzen der Exhaustivitätsprüfung, geplante client-seitige Validierung: README_ext.md, "Validator — Hintergründe".)

Token-Strategie (tokens.ts)

ITokenStrategy bündelt jede Token-Konstruktion/-Transition hinter einem austauschbaren Interface:

interface ITokenStrategy {
    fork(token: IToken, targetNodeIDs: string[]): IToken[];
    cancel(token: IToken): IToken;
    nextIteration(token: IToken, loopClosingNodeID: string, nextPositionNodeID: string): IToken;
    merge(survivor: IToken, retiredSiblingIDs: string[]): IToken;
}
  • fork — baut die Kind-Tokens eines Fan-outs (AND-/OR-/EVENT-Split); das Retirieren des Eltern-Tokens ist Sache der Engine.
  • cancel — retiriert ein redundant gewordenes Token.
  • nextIteration — liefert das Token, mit dem eine Schleife weiterläuft. Ändert sich die id gegenüber dem übergebenen Token, muss der Aufrufer das alte Token zusätzlich als inaktiv persistieren.
  • merge — entscheidet, welches Token nach einem AND-/OR-Join-Merge weiterläuft. Gleicher Id-Wechsel-Vertrag wie nextIteration.

Zwei mitgelieferte Implementierungen:

  • ReuseTokenStrategy — behält bei einer Schleifen-Iteration dieselbe Token-id.
  • NewTokenPerIterationStrategy (Default in WorkflowEngineBuilder) — vergibt bei jeder Schleifen-Iteration eine neue id, mit parentTokenID auf die vorige.

Beide implementieren merge als reines Passthrough (survivor unverändert zurückgegeben).

(Warum merge als eigene Methode statt Engine-internem Workaround: README_ext.md, "Token-Strategie — merge Rationale".)

Engine (engine.ts, Typmodell in engineModel.ts)

IWorkflowEngine (Interface in engineModel.ts; implementiert von BasicWorkflowEngine, gebaut über WorkflowEngineBuilder, beide engine.ts) exportiert einen Instanz-Erzeugungs-Einstiegspunkt plus vier Trigger-Methoden:

interface IWorkflowEngine {
    start(definitionID: string, startNodeID: string, initialContext?: Record<string, any>): Promise<string>;
    onTaskCompleted(instanceID: string, tokenID: string, result: unknown): Promise<void>;
    onMessageReceived(instanceID: string, tokenID: string, payload: unknown): Promise<void>;
    onSignalReceived(instanceID: string, tokenID: string, payload: unknown): Promise<void>;
    onTimerFired(instanceID: string, tokenID: string): Promise<void>;
}

start — legt eine neue IWorkflowInstance an, platziert ihren ersten Token an startNodeID (muss ein IStartNode von definitionID sein), treibt sie sofort so weit voran, wie ohne externen Trigger möglich ist, und liefert die neue instanceID. Eine Definition darf mehrere IStartNodes haben (IStartConfig.triggerType: MANUAL/MESSAGE/SIGNAL/TIMER) — der Aufrufer entscheidet über startNodeID, welcher Einstiegspunkt gemeint ist, die Engine rät nicht.

Jede der übrigen vier Methoden lädt die aktuelle Instanz und Definition, wendet die Änderung an, lässt sie ggf. durch beliebig viele Pass-Through-Gateways kaskadieren, und persistiert das Ergebnis.

Pro Knotentyp beim Betreten (enterNode):

  • TASK — hängt Boundary-Watcher an (siehe "Events" oben), projiziert payload (siehe "Tasks" oben), ruft ITaskDispatcher.dispatch, ruft IEngineNotifier.onTaskDispatched (siehe "Konstanten & Engine-Benachrichtigung" unten).
  • EVENT — parkt, wartet auf onMessageReceived/onSignalReceived/onTimerFired.
  • END — retiriert das Token.
  • GATEWAY — löst sofort auf (resolveGateway, siehe "Gateways"/"Schleifen" oben).
  • START — als Zwischenziel (d. h. mitten im laufenden Fluss, nicht als Startposition durch start selbst) ein Fehler (EEngineFaultType.INVARIANT_VIOLATION) — ein START-Knoten ist nur als initiale Position gültig, nie als Kantenziel.

Fehlerbehandlung:

  • IRetryPolicy (allgemeiner Retry-Port, adapters.ts — auch von InProcessTimerSchedulers onceRetryPolicy verwendet, siehe "Timer Scheduler" unten) — steuert hier, ob/wie oft nach einem VersionConflictError von IStateStorage.save neu geladen, neu berechnet und erneut gespeichert wird (ExponentialBackoffRetryPolicy, Default: 5 Versuche, exponentiell wachsende, gejitterte Pausen; FixedDelayRetryPolicy als einfachere Alternative mit fester Pause, Default 3 Versuche à 1000ms). Gilt für die vier Trigger-Methoden; start hat keine Retry-Schleife (siehe unten, warum). Konstruktor-Optionen von ExponentialBackoffRetryPolicy (IExponentialBackoffRetryPolicyOptions, alle optional): maxAttempts (Default 5), baseDelayMs (Default 50), maxDelayMs (Default 2000), jitter (Default true). Konstruktor-Optionen von FixedDelayRetryPolicy (IFixedDelayRetryPolicyOptions, alle optional): maxAttempts (Default 3), delayMs (Default 1000).
  • EEngineFaultType — UNEXPECTED_TOKEN_STATE, CONDITION_EVALUATION_FAILED, INVARIANT_VIOLATION, UNKNOWN. Bei einem nicht-retrybaren Fehler (in start ebenso wie in den drei Trigger-Methoden): IEngineErrorHandler.onFault wird best-effort aufgerufen, ein FAULT-Eintrag best-effort ins Activity Log geschrieben, jedes noch aktive, an einem EVENT-Knoten geparkte Token best-effort disarmt (reason: FAILED, siehe "Event-Knoten-Lifecycle-Hook" unten), die Instanz best-effort als EWorkflowState.FAILED mit IWorkflowError gespeichert — in dieser Reihenfolge, und keiner der vier Schritte maskiert bei einem eigenen Fehler die anderen oder den ursprünglichen Fault, der so oder so an den Aufrufer weitergereicht wird. Payload von IEngineErrorHandler.onFault (adapters.ts):
interface IEngineFault {
    readonly type: EEngineFaultType;
    readonly instanceID: string;
    readonly tokenID?: string; // fehlt, wenn der Fault keinem Token zuzuordnen ist — u. a. nach einem Fork (AND-/OR-Split, Boundary-Watcher): das ursprüngliche, bereits retirierte Token wäre eine Falschzuordnung, welches der neuen Kind-Tokens tatsächlich betroffen ist, ist nicht mehr rekonstruierbar
    readonly nodeID?: string;  // Knoten, an dem das betroffene Token stand/hinwollte, falls bekannt
    readonly error: unknown;   // der ursprüngliche geworfene Wert, unverändert
}
  • EWorkflowState — STARTING (nur während start selbst läuft, wird nie persistiert zurückgegeben — der erste tatsächlich gespeicherte Zustand ist bereits WAITING/SUCCEEDED/FAILED), RUNNING (nur während ein Schritt läuft, nie persistiert), WAITING/SUCCEEDED (abhängig davon, ob nach einem Schritt noch aktive Tokens existieren), FAILED.

start läuft über denselben IInstanceLock wie die vier Trigger-Methoden, aber NICHT über deren Retry-Schleife bei VersionConflictError: die instanceID wird frisch generiert, es gibt also keinen existierenden, nebenläufig erreichbaren Datensatz, gegen den ein VersionConflictError konkurrieren könnte. Den Lock braucht start trotzdem — der erste erreichte Knoten kann ein TASK/EVENT sein, dessen Dispatch/Arm schon läuft, bevor die Instanz selbst zum ersten Mal gespeichert ist; ohne Lock könnte ein schnell genug zurückkommender Trigger (onTaskCompleted/onMessageReceived/onSignalReceived/onTimerFired, alle über step()) in genau dieses Zeitfenster fallen und die Instanz noch nicht vorfinden (siehe README_ext.md, "Engine — start als eigene Methode, mit Lock aber ohne Retry"). Ein Fehler mittendrin (z. B. wenn schon der erste erreichte Knoten ein TASK ist, dessen ITaskDispatcher.dispatch wirft) durchläuft trotzdem dieselbe Fault-Klassifizierung/-Meldung wie oben — eine fehlgeschlagene Instanz-Erzeugung ist genauso sichtbar wie ein Fehler mitten im Ablauf, verschwindet also nicht kommentarlos.

Konstanten & Engine-Benachrichtigung (EEngineBusEventType/die Payload-Interfaces in engineModel.ts, IEngineNotifier in adapters.ts)

IEngineNotifier ist der Port, über den die Engine der Außenwelt tatsächlich etwas mitteilt — ein typisiertes Callback-Interface, kein Message-Bus:

interface IEngineNotifier {
    onTaskDispatched(payload: IEngineBusTaskDispatchedPayload): void | Promise<void>;
    onInstanceTransitioned(payload: IEngineBusInstanceTransitionedPayload): void | Promise<void>;
    onInstanceCompleted(payload: IEngineBusInstanceTransitionedPayload): void | Promise<void>;
}
  • onTaskDispatched — direkt nach jedem ITaskDispatcher.dispatch-Aufruf. Payload: IEngineBusTaskDispatchedPayload { instanceID, tokenID, nodeID, taskType, targetVersion }.
  • onInstanceTransitioned — am Ende jedes Schritts (start, onTaskCompleted, onMessageReceived, onSignalReceived, onTimerFired), NACHDEM erfolgreich gespeichert wurde, mit dem neuen EWorkflowState (WAITING/SUCCEEDED/FAILED). Payload: IEngineBusInstanceTransitionedPayload { instanceID, state, targetVersion }.
  • onInstanceCompleted — zusätzlich zu onInstanceTransitioned, nur wenn state SUCCEEDED oder FAILED ist (nie WAITING) — die Instanz wird nie wieder einen Zustand wechseln. Gleiches Payload wie onInstanceTransitioned. Feuert auch für FAILED (inkl. dem Fault-Pfad in start), nicht nur für SUCCEEDED.

Save-then-notify, nicht umgekehrt: schlägt save() fehl (auch mit etwas anderem als VersionConflictError), wird für diesen Versuch überhaupt keine Notification verschickt — nur ein tatsächlich durchgängig persistierter Übergang wird je gemeldet. Das ist ein bewusster Trade-off: bei einem Fehler zwischen erfolgreichem save() und der Notification (z. B. IEngineNotifier selbst wirft) geht diese eine Notification verloren, statt (wie bei "notify-vor-save") potenziell doppelt/widersprüchlich für denselben targetVersion verschickt zu werden. Ein Consumer, für den keine verlorene Notification tolerierbar ist, muss selbst nachfragen (IStateStorage.load/IActivityLogStorage.listForInstance) statt sich allein auf IEngineNotifier zu verlassen — dieselbe Empfehlung, die für das ohnehin nur "at-least-once" (nicht "exactly-once") zugesicherte targetVersion-Dedupe unten bereits gilt.

targetVersion ist der Dedupe-Schlüssel-Bestandteil aus IEngineNotifiers At-least-once-Vertrag (siehe adapters.ts) — dieselbe Semantik wie zuvor, nur am neuen Port statt an IEventBus.

EEngineBusEventType (engineModel.ts) ist keine Menge, die die Engine selbst verschickt, sondern die feste Namens-Vokabel, die createEventBusEngineNotifier (adapters.ts) beim Überführen von IEngineNotifier-Aufrufen auf ein echtes IEventBus benutzt: TASK_DISPATCHED ("task.dispatched"), INSTANCE_TRANSITIONED ("instance.transitioned"), INSTANCE_COMPLETED ("instance.completed").

combineEngineNotifiers(...notifiers): IEngineNotifier (adapters.ts) — Fan-out: baut aus mehreren IEngineNotifiers einen einzigen, der WorkflowEngineBuilder.withEngineNotifier entgegennimmt (die nur genau einen akzeptiert). Alle gegebenen Notifier werden für jeden Aufruf immer alle aufgerufen (parallel, nie nacheinander), auch wenn einer davon wirft — genau ein werfender: dessen Fehler wird weitergereicht; mehr als einer: ein AggregateError mit allen.

(Warum IEngineNotifier und nicht mehr IEventBus direkt, wie sich die beiden Ports zueinander verhalten, und warum onInstanceCompleted eine eigene Methode ist: README_ext.md, "Engine-Benachrichtigung — von IEventBus zu IEngineNotifier".)

(Warum keine Sagas/Kompensation, und wie sich IEngineErrorHandler/IActivityLogStorage unabhängig ergänzen: README_ext.md, "Engine — Fehlerbehandlung: warum keine Sagas/Kompensation".)

WorkflowEngineBuilder — je eine with...-Methode pro Abhängigkeit:

| Methode | Pflicht? | Default | |---|---|---| | withStateStorage | ja | — | | withTaskDispatcher | ja | — | | withEngineNotifier | ja | — | | withInProcessLock / withDistributedLock | ja (genau eine von beiden) | withInProcessLock(lock?): InProcessInstanceLock, falls kein IInstanceLock übergeben wird | | withErrorHandler | ja | — | | withActivityLogStorage | ja | — | | withDefinitionRegistry | ja | — | | withConditionEvaluator | ja | — | | withTokenStrategy | nein | NewTokenPerIterationStrategy | | withSaveRetryPolicy | nein | ExponentialBackoffRetryPolicy | | withEventNodeLifecycleHook | nein | LoggingEventNodeLifecycleHook (No-op-Stub) |

build() wirft, wenn eine Pflichtabhängigkeit fehlt.

Distributed Lock (IDistributedLockAdapter/IDistributedLockHandle, adapters.ts)

Für withDistributedLock (Mehrprozess-/Cloud-Deployments, siehe Tabelle oben) implementiert die Anwendung diese beiden Ports gegen ihre eigene Koordinationstechnologie (DB-Row-Lock/-Transaktion, Redis-Lock, ZooKeeper-/etcd-Lease, …); wflib selbst hat keine Meinung dazu, welche das ist:

interface IDistributedLockAdapter {
    acquire(instanceID: string): Promise<IDistributedLockHandle>;
}

interface IDistributedLockHandle {
    renew(): Promise<void>;
    release(): Promise<void>;
    readonly renewIntervalMs: number; // wie oft DistributedInstanceLock renew() aufruft, während ein Schritt läuft
}

DistributedInstanceLock (mitgelieferte IInstanceLock-Implementierung, s. u.) ruft acquire einmal pro withLock-Aufruf, ruft periodisch renew auf und ruft release immer auf, auch wenn die geschützte Arbeit wirft. Eine Renewal-Fehlermeldung macht das Gesamtergebnis nicht vertrauenswürdig (IWorkflowInstance.versions optimistisches Locking beim save() bleibt die eigentliche letzte Verteidigungslinie, nicht das Lease selbst — siehe Kleppmanns bekannte Kritik an naiven verteilten Locks).

Event-Knoten-Lifecycle-Hook (IEventNodeLifecycleHook in adapters.ts)

Ein optionaler Port (anders als IEngineNotifier/IEngineErrorHandler KEINE Pflichtabhängigkeit von WorkflowEngineBuilder) für einen Consumer, der bei jedem Parken/Verlassen eines Tokens an einem EVENT-Knoten etwas Externes scharf-/entschärfen muss (ein Wall-Clock-Timer, ein Message-Bus-Abonnement, …), und zwar so, dass es einen Prozess-Neustart übersteht:

interface IEventNodeLifecycleHook {
    onEventNodeEntered(ctx: IEventNodeEnteredContext): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
    onEventNodeLeft(ctx: IEventNodeLeftContext): Promise<void> | void;
}
  • onEventNodeEntered — aufgerufen, sobald ein Token frisch an einem EVENT-Knoten geparkt wurde (inline und Boundary-Watcher identisch behandelt). Was hier zurückgegeben wird, landet in IWorkflowInstance.externalData[tokenID] und wird mit demselben save()-Aufruf persistiert, den die Engine für diesen Schritt ohnehin schon vorhatte — kein zusätzlicher I/O-Umweg. undefined/nichts zurückgeben speichert nichts für dieses Token.
  • onEventNodeLeft — aufgerufen, kurz bevor ein Token endgültig aufhört, an einem EVENT-Knoten geparkt zu sein, mit einem reason (EEventNodeDepartureReason, ein echtes Enum, kein roher String) für WARUM: RESOLVED (es hat selbst gefeuert/wurde aufgelöst), CANCELLED (anderweitig verloren/storniert, z. B. eine verlierende EVENT-Split-Race-Branch, oder ein Boundary-Watcher, der zusammen mit seinem Host abgebaut wird), oder FAILED (die Instanz ist in FAILED übergegangen, während dieses Token noch hier geparkt war — siehe direkt unten). Bekommt das zurück, was onEventNodeEntered zuvor für dieses Token gespeichert hat (ctx.data). Was auch immer onEventNodeLeft selbst tut — der Eintrag in externalData wird direkt danach in jedem Fall entfernt.

Verallgemeinert über jeden TEventNode (nicht auf TIMER beschränkt) — eine MESSAGE-Event hat dieselbe "hierfür muss extern etwas scharf gestellt werden"-Form wie eine TIMER-Event, auch wenn timerScheduler.tss mitgelieferte Implementierung (siehe unten) nur TIMER tatsächlich behandelt.

Bewusst ein SEPARATER Port von IEngineNotifier, obwohl beide "die Engine sagt der Außenwelt etwas": IEngineNotifiers drei Methoden sind reine, folgenlose Meldungen (Rückgabewert nie konsultiert); onEventNodeEntereds Rückgabewert wird dagegen konsultiert und dauerhaft persistiert — eine kategorisch andere Form, die einen eigenen Vertrag verdient statt IEngineNotifier dafür zu verbiegen.

FAILED-Übergang mit noch aktiven EVENT-Knoten: Geht eine Instanz in FAILED über, während ein oder mehrere Tokens noch aktiv an einem EVENT-Knoten geparkt sind (ein unabhängiger Fault im selben oder einem späteren Schritt, egal ob am selben Token oder an einem völlig anderen), ruft reportTriggerFault (engine.ts) onEventNodeLeft für jedes von ihnen mit reason: FAILED auf — best-effort, wie die übrigen Meldungsschritte dieser Methode (ein einzelner werfender Hook-Aufruf stoppt nicht die übrigen und maskiert nie den ursprünglichen Fault).

LoggingEventNodeLifecycleHook (adapters.ts) — No-op-Stub, gleiche Rolle wie LoggingEngineErrorHandler/LoggingEngineNotifier. Anders als diese beiden ist er nicht nur ein Platzhalter zum Erfüllen einer Pflichtabhängigkeit, sondern selbst schon der Default, wenn withEventNodeLifecycleHook nie aufgerufen wird.

Startup-Reconciliation über IStateStorage.listInstanceIDsByState: IStateStorage (adapters.ts) hat dafür eine zusätzliche Methode:

listInstanceIDsByState(state: EWorkflowState, opts?: { cursor?: string; limit?: number }): Promise<IInstanceIDPage>;
// IInstanceIDPage: { ids: string[]; nextCursor?: string }

Bewusst die einzige Abfragefähigkeit auf diesem Port (keine allgemeine Filter-/Sortier-DSL) — der Anlass ist explizit: bei Prozess-Start alle noch WAITING-Instanzen finden, damit alles, was extern erneut scharf gestellt werden muss (siehe oben), tatsächlich passiert, statt nur beim einen Mal scharf gestellt zu werden, als zufällig ein Prozess lief. Paginierung ist fester Vertragsbestandteil (opts.limit/opts.cursor/das zurückgegebene nextCursor, opak — nie selbst konstruieren oder interpretieren, nur verbatim zurückreichen), nicht nachträglich angeflanscht.

(Warum eine schmale, zweckgebundene Methode statt eines allgemeinen Query-Mechanismus, und wie IEventNodeLifecycleHook/externalData zusammenspielen: README_ext.md, "Event-Knoten-Reinitialisierung — Design-Hintergründe".)

Activity Log (activityLog.ts)

IActivityLogStorage.append/listForInstance — append-only, pro Instanz. EActivityLogEntryType: NODE_ENTERED, NODE_COMPLETED, TASK_DISPATCHED, TOKEN_FORKED, TOKEN_CANCELED, LOOP_ITERATED, FAULT, EVENT_NODE_ARMED, EVENT_NODE_DISARMED. Jeder Eintrag: id, instanceID, type, tokenID?, nodeID?, timestamp (ISO-String), detail?.

EVENT_NODE_ARMED/EVENT_NODE_DISARMED — geloggt von armEventNode/disarmEventNode (engine.ts), aber NUR wenn der konfigurierte IEventNodeLifecycleHook tatsächlich etwas zurückgegeben hat/etwas Gespeichertes abzuräumen war — ein Hook, der sich für diesen EEventType gar nicht interessiert (z. B. TimerSchedulerEventNodeLifecycleHook für ein MESSAGE-Event), erzeugt keinen Log-Eintrag, weil er auch nichts scharf gestellt hat. EVENT_NODE_DISARMEDs detail.reason ist eine EEventNodeDepartureReason (siehe "Event-Knoten-Lifecycle-Hook" unten).

TappingActivityLogStorage — Decorator um ein echtes IActivityLogStorage: ruft bei jedem append() zusätzlich einen mitgegebenen Callback auf (Live-Feed, z. B. für eine UI oder ein Websocket), NACHDEM die eigentliche Persistierung durchgelaufen ist. listForInstance wird unverändert an das eingepackte Storage durchgereicht.

Architektur: Ports & Adapters (adapters.ts)

| Port | Zweck | |---|---| | ITaskDispatcher | Task-Ausführung an einen externen Worker delegieren | | IStateStorage | IWorkflowInstance laden/persistieren — create für den allerersten Persist einer neuen Instanz (wirft bei bereits existierender id), save für jeden weiteren (Optimistic Locking über version, wirft ebenfalls, wenn die id noch gar nicht existiert), plus listInstanceIDsByState für Startup-Reconciliation (siehe "Event-Knoten-Lifecycle-Hook" oben) | | IEngineNotifier | die Engine benachrichtigt die Außenwelt (Dispatch, Transition, Completion) — siehe "Konstanten & Engine-Benachrichtigung" oben | | IEventBus | allgemeiner, generischer Message-Bus — von der Engine selbst nicht mehr direkt verwendet, siehe unten | | IWorkflowDefinitionRegistry | validierte IWorkflowDefinition per definitionID laden | | IConditionEvaluator | JSONLogic-Bedingungen einer Kante gegen context auswerten | | IInstanceLock | Ausführung pro instanceID exklusiv serialisieren | | IEngineErrorHandler | Benachrichtigung bei einem unerwarteten Fault | | IRetryPolicy | Retry-Verhalten nach VersionConflictError (auch von InProcessTimerSchedulers onceRetryPolicy verwendet, siehe "Timer Scheduler" unten) | | IEventNodeLifecycleHook | optional — Arm/Disarm-Hook für EVENT-Knoten, siehe "Event-Knoten-Lifecycle-Hook" oben |

IActivityLogStorage ist kein Teil von adapters.ts — es sitzt zusammen mit EActivityLogEntryType in activityLog.ts (siehe "Activity Log" oben).

Mitgelieferte Implementierungen: InProcessInstanceLock/DistributedInstanceLock (IInstanceLock), ExponentialBackoffRetryPolicy/FixedDelayRetryPolicy (IRetryPolicy), LoggingEngineErrorHandler (IEngineErrorHandler, No-op-Stub), LoggingEngineNotifier (IEngineNotifier, No-op-Stub), createEventBusEngineNotifier(eventBus) (baut ein IEngineNotifier aus einem gegebenen IEventBus, benutzt EEngineBusEventTypes Namen), combineEngineNotifiers(...notifiers) (Fan-out mehrerer IEngineNotifiers zu einem, siehe "Konstanten & Engine-Benachrichtigung" oben).

VersionConflictError — von IStateStorage.save zu werfen, wenn die Optimistic-Locking-Prüfung fehlschlägt.

Dependency-freie In-Memory-Implementierungen

Für Tests, lokale Entwicklung und Single-Process-Prototypen — nicht für Mehrprozess-/Produktionsbetrieb (Zustand lebt nur im Prozessspeicher):

  • InMemStateStorage (IStateStorage) — Map-basiert, erzwingt denselben Optimistic-Locking-Vertrag wie jede echte Implementierung (VersionConflictError bei Versions-Mismatch, version + 1 bei Erfolg). create() wirft, wenn die id bereits existiert; save() wirft, wenn sie noch NICHT existiert (ein Tippfehler in der id bleibt so nicht unbemerkt an der falschen Methode hängen). toJSON()/static fromJSON(data) für Snapshot/Restore.
  • InMemWorkflowRegistry (IWorkflowDefinitionRegistry) — Map-basiert. Zusätzlich zu load: add(definition), remove(definitionID), has(definitionID), list(). toJSON()/static fromJSON(data) für Snapshot/Restore.
  • InMemEventBus (IEventBus) — In-Process-Publish/Subscribe. Zusätzlich zu emit: on(eventName, handler)/onAny(handler) (beide geben eine Unsubscribe-Funktion zurück), waitFor(eventName, predicate?) (Promise, löst beim nächsten passenden Event auf). Ein werfender Handler bricht emit() nie ab — Fehler gehen an den optionalen Konstruktor-Parameter onSubscriberError?(eventName, err), Default: stillschweigend verworfen. Typischerweise erreicht über createEventBusEngineNotifier (siehe oben), nicht direkt von der Engine verwendet.
  • NodeEventEmitterEventBus (IEventBus) — zweite, gleichwertige Referenzimplementierung, auf Basis von node:events' EventEmitter statt eigener Map/Set-Buchhaltung für den benannten Teil. Identische öffentliche Oberfläche und identisches Verhalten wie InMemEventBus (emit/on/onAny/waitFor, gleicher onSubscriberError-Vertrag) — beide sind austauschbar, ohne dass ein Konsument den Unterschied bemerkt.

Timer Scheduler (timerScheduler.ts, optional)

Nur für Single-Server-/Single-Process-Betrieb. InProcessTimerScheduler, rearmTimersFromStorage und TimerSchedulerRuntime (siehe unten) sind für GENAU EINEN laufenden Prozess konzipiert — mehrere gleichzeitig laufende Replikas (Mehrprozess-/horizontale Skalierung, z. B. mehrere Container-Instanzen desselben Deployments) rearmen beim Start jede für sich unabhängig dieselben Instanzen: doppelte Timer/Feuerungen sind die Folge (keine Datenkorruption — IInstanceLock serialisiert step() weiterhin, siehe "Engine" oben). Wer Timer über mehrere Prozesse hinweg koordiniert scharf stellen will (Leader-Wahl, Sharding nach instanceID, ein verteilter Scheduler, …), braucht dafür ein anderes System — das ist bewusst außerhalb des Scopes dieses Bausteins, siehe README_ext.md, "Timer-Scheduler-Bootstrap — cancelAll()/TimerSchedulerRuntime", für die Details.

Ein eigenständiger, komplett von engine.ts/engineModel.ts entkoppelter Baustein (kein Import in beide Richtungen) — die Engine selbst hat bewusst keinen Port, der einen TIMER tatsächlich scharf stellt (siehe "Bewusst nicht im Modell enthalten" bzw. die Engine-Dokumentation oben: onTimerFired reagiert nur auf ein bereits erfolgtes Feuern). timerScheduler.ts ist die dazu passende, optionale Gegenstück-Schicht für Consumer, die das nicht von Hand bauen wollen — eingebunden wird sie explizit vom Consumer selbst, nie automatisch von WorkflowEngineBuilder:

const scheduler = new InProcessTimerScheduler(async (instanceID, tokenID) => {
    await engine.onTimerFired(instanceID, tokenID);
});
interface ITimerScheduler {
    schedule(instanceID: string, tokenID: string, config: ITimerEventConfig): void;
    cancel(instanceID: string, tokenID: string): void;
    cancelAll(): void;
}
  • schedule — stellt einen Timer für das gegebene (instanceID, tokenID)-Paar scharf, gemäß ITimerEventConfig (timerDate/timerDuration/timerCycle — genau eines davon). Erneutes schedule() für dasselbe Paar ersetzt den vorherigen Timer, statt einen zweiten, konkurrierenden zu starten. Wirft synchron, wenn keines der drei Felder gesetzt ist, oder timerCycle nicht der unterstützten "R/<ISO-8601-Dauer>"-Form entspricht (z. B. "R/PT24H") — die volle ISO-8601-Wiederholungsgrammatik (Wiederholungszähler, explizite Start-/End-Daten) ist bewusst nicht unterstützt.
  • cancel — deaktiviert einen scharfen Timer; harmloses No-op, wenn für das Paar nichts (mehr) scharf ist.
  • cancelAll — deaktiviert JEDEN aktuell scharfen Timer dieses Schedulers in einem Aufruf, für sauberes Herunterfahren, ohne dass ein Consumer selbst Buch führen müsste, welche (instanceID, tokenID)-Paare er je scharf gestellt hat. Harmloses No-op, wenn nichts scharf ist.

InProcessTimerScheduler — die mitgelieferte Referenzimplementierung, auf Basis von @incoqnito.io/ts-iq-cores repeatAtTime (bereits eine harte Projekt-Abhängigkeit, zählt also nicht als neue Dependency) für alle drei ITimerEventConfig-Varianten einheitlich. Kontrollierbar per (instanceID, tokenID)-Paar über einen eigenen AbortController je Timer. Serialisierbar über toJSON()/static fromJSON(data, onFire, opts?) — gleiches Snapshot/Restore-Muster wie InMemStateStorage/InMemWorkflowRegistry (adapters.ts); persistiert wird dabei stets der bereits aufgelöste absolute Feuerzeitpunkt (fireAt/nextFireAt), nie die rohe ITimerEventConfig — timerDurations "relativ zur Aktivierung" ist zum Zeitpunkt von schedule() fixiert und würde sich beim Neu-Berechnen nach einem Prozess-Neustart sonst stillschweigend verschieben. Ein überfälliger, aus einem Snapshot wiederhergestellter Einmal-Timer feuert beim Wiederherstellen nahezu sofort ("Catch-up"). Die konkrete Snapshot-Form ist als eigener, ebenfalls exportierter Typ verfügbar, falls ein Consumer sie selbst persistieren will, statt toJSON()s Rückgabewert nur durchzureichen: TTimerSchedulerSnapshot (ein Record<string, TArmedTimerRecord>, ein Eintrag pro scharf gestelltem (instanceID, tokenID)-Paar), TArmedTimerRecord (unterscheidet nach kind: EArmedTimerKind zwischen ONCE/CYCLE).

Nachhol-Verhalten für CYCLE-Timer nach Ausfallzeit (maxCatchUpFires, Konstruktor-/fromJSON-Option, Default Infinity): War ein CYCLE-Timer länger überfällig (der Prozess war zwischenzeitlich down/blockiert), bestimmt maxCatchUpFires, wie viele der verpassten Feuerungen tatsächlich noch nachgeholt werden, bevor der Rest verworfen und direkt auf den nächsten, zukünftigen Zeitpunkt gesprungen wird:

new InProcessTimerScheduler(onFire, { maxCatchUpFires: 1 });
  • Infinity (Default) — jede verpasste Feuerung wird nachgeholt (heutiges, unverändertes Verhalten — kein stiller Verhaltenswechsel für bestehende Consumer).
  • 0 — keine einzige verpasste Feuerung wird nachgeholt; der erste überfällige Tick springt sofort auf jetzt + Intervall.
  • 1 — genau eine Nachhol-Feuerung, danach wird der Rest des Rückstands übersprungen.
  • N — bis zu N Nachhol-Feuerungen, danach wird der Rest übersprungen.

Der Zähler wird bei jeder pünktlichen (nicht rückständigen) Feuerung sowie beim Überspringen selbst zurückgesetzt — ein späterer, eigenständiger Ausfall bekommt sein eigenes, volles Kontingent. Gilt scheduler-weit für alle über diese Instanz scharf gestellten CYCLE-Timer, nicht pro einzelnem Timer konfigurierbar — Nachhol-Verhalten ist eine Scheduler-Implementierungsentscheidung, keine Eigenschaft der Workflow-Definition (ITimerEventConfig, workflowModel.ts, bleibt dafür bewusst unangetastet).

Retry-Verhalten für ONCE-Timer nach einem werfenden onFire (onceRetryPolicy, Konstruktor-/fromJSON-Option, Default undefined): Ohne konfigurierte Policy verliert ein werfendes onFire den timerDate/timerDuration-Timer unwiderruflich (unverändertes Altverhalten). Mit einer konfigurierten IRetryPolicy (adapters.ts — derselbe Port wie withSaveRetryPolicy oben, z. B. FixedDelayRetryPolicy oder ExponentialBackoffRetryPolicy) wird stattdessen onceRetryPolicy.nextDelayMs(attempt) befragt (attempt beginnt bei 1); liefert das einen Wert, wird der Timer nach dieser Verzögerung erneut scharf, statt verworfen zu werden; liefert es undefined, wird der Timer wie im Default-Fall abgemeldet und der Fehler weitergereicht:

new InProcessTimerScheduler(onFire, { onceRetryPolicy: new FixedDelayRetryPolicy({ maxAttempts: 3, delayMs: 1000 }) });

Der Versuchszähler lebt nur zur Laufzeit (nicht im TArmedTimerRecord/Snapshot) — ein Prozess-Neustart mitten in einer Retry-Serie beginnt mit einem frischen Kontingent, dieselbe bewusste Design-Entscheidung wie beim maxCatchUpFires-Zähler oben. Betrifft ausschließlich ONCE-Timer; CYCLE-Timer haben mit maxCatchUpFires ihren eigenen, unabhängigen Mechanismus. Ein cancel(), das während einer noch ausstehenden Retry-Verzögerung eintrifft, stoppt sauber — kein weiterer onFire-Aufruf, kein fälschlich als endgültiger Fehler geloggter Abbruch.

TimerSchedulerEventNodeLifecycleHook — die konkrete Brücke zwischen einem ITimerScheduler und dem neuen IEventNodeLifecycleHook-Port (siehe "Event-Knoten-Lifecycle-Hook" oben): onEventNodeEntered ruft scheduler.schedule(...) und gibt einen kleinen Marker zurück ({ eventType: EEventType.TIMER }), aber NUR für einen TIMER-Event-Knoten — jeder andere EEventType bleibt unangetastet (Rückgabe undefined, kein schedule()-Aufruf). onEventNodeLeft ruft symmetrisch scheduler.cancel(...), wieder nur für TIMER — unabhängig vom reason (RESOLVED/CANCELLED/FAILED behandelt diese Brücke identisch). Der aufgelöste Feuerzeitpunkt selbst wird bewusst NICHT im Marker dupliziert — der lebt bereits in InProcessTimerSchedulers eigenem toJSON()-Snapshot; der Marker dient nur dazu, rearmTimersFromStorage (unten) zu sagen "für dieses Token ist etwas scharf zu stellen", ohne dafür in ITimerSchedulers internen Zustand schauen zu müssen. Verdrahtung:

const scheduler = new InProcessTimerScheduler(async (instanceID, tokenID) => {
    await engine.onTimerFired(instanceID, tokenID);
});
const builder = new WorkflowEngineBuilder()
    // ...
    .withEventNodeLifecycleHook(new TimerSchedulerEventNodeLifecycleHook(scheduler));

rearmTimersFromStorage(stateStorage, definitionRegistry, scheduler, states = [EWorkflowState.WAITING]) — Startup-Reconciliation: läuft über IStateStorage.listInstanceIDsByState (paginiert, folgt jedem nextCursor bis zum Ende) jede Instanz in states ab, lädt sie samt ihrer IWorkflowDefinition (definitionRegistry, dasselbe Objekt, das auch die Engine selbst benutzt), und stellt für jedes noch aktive Token, das an einem TIMER-EVENT-Knoten geparkt ist, scheduler.schedule(...) erneut scharf. Nötig, weil weder InProcessTimerSchedulers In-Memory-Zustand noch dessen toJSON()-Snapshot etwas von einem Timer wissen können, der nie von einem noch laufenden Prozess scharf gestellt wurde — ein frischer Prozess (kein Snapshot, oder einer von vor dem Token-Eintritt) hat sonst keine andere Möglichkeit, das herauszufinden. Einmal beim Start aufrufen, direkt nach dem Bau von scheduler, bevor er über TimerSchedulerEventNodeLifecycleHook frische onEventNodeEntered-Aufrufe entgegennimmt. Ein bereits überfälliger Timer feuert dabei nahezu sofort — dieselbe "Catch-up"-Semantik wie InProcessTimerScheduler.fromJSON().

TimerSchedulerRuntime — kleiner, optionaler Lifecycle-Wrapper, der genau zwei Dinge in start()/stop() packt, statt sie dem Consumer als zwei separat zu merkende freie Funktionsaufrufe zu überlassen:

const runtime = new TimerSchedulerRuntime(scheduler, stateStorage, definitionRegistry);
await runtime.start(); // ruft rearmTimersFromStorage — vor dem ersten externen Trigger aufrufen
// ... Prozess läuft, nimmt Trigger entgegen ...
runtime.stop(); // ruft scheduler.cancelAll() — beim Herunterfahren

Baut NICHT selbst den Scheduler oder die Engine (das bleibt WorkflowEngineBuilders bzw. euer eigener Job, inklusive des Closures, das den Scheduler mit der noch nicht gebauten Engine verdrahtet — siehe Beispiel oben) — reine Bootstrap-Reihenfolge-Hilfe für das, was danach passiert. Löst NICHT den Mehrprozess-/horizontal-Skalierungs-Fall (mehrere Replikas, die beim Start jede für sich dieselben Instanzen rearmen) — bewusst außerhalb des Scopes.

(Warum diese Implementierungen existieren, und die Designentscheidungen dahinter: README_ext.md, "Dependency-freie In-Memory-Adapter — Hintergründe".)

BPMN-Export (bpmnExport.ts, optional)

Ein eigenständiger, komplett von engine.ts entkoppelter Baustein: exportToBpmn(definition: IWorkflowDefinition, options?: IBpmnExportOptions): string wandelt eine validierte Definition in ein vollständiges BPMN-2.0-XML-Dokument (<bpmn:definitions>...</bpmn:definitions>, inklusive XML-Deklaration).

const xml = exportToBpmn(definition, { isExecutable: false, targetNamespace: "urn:example:mein-prozess" });

Reiner Export, kein Import. (Warum ein allgemeiner BPMN-Import nicht sinnvoll umsetzbar ist: README_ext.md, "BPMN-Kompatibilität — Im-/Export, Abwägungen".)

Nicht generiert: Diagramm-Layout (bpmndi). Das erzeugte XML ist strukturell vollständig und BPMN-valide, enthält aber keine Koordinaten. Ein Tool, das das Diagramm tatsächlich zeichnen will (bpmn-js, Camunda Modeler, …), braucht dafür einen separaten Auto-Layout-Schritt — z. B. das externe Paket bpmn-auto-layout. Das ist bewusst kein Teil dieses Bausteins.

Bedingungen (IWorkflowEdge.condition, JSONLogic) werden nicht in eine conditionExpression übersetzt. BPMN erwartet dort FEEL/JUEL/Groovy, je nach Ziel-Engine — keine davon ist JSONLogic, und eine automatische Übersetzung riskiert, plausibel auszusehen, aber etwas anderes zu bedeuten als das Original. Stattdessen schreibt exportToBpmn eine menschenlesbare Wiedergabe der JSONLogic-Struktur in ein <bpmn:documentation>-Element der Kante (über die separat exportierte Funktion humanizeJsonLogic, s. u.) — für einen Menschen, der sich das Diagramm anschaut, nicht für eine ausführende Engine.

humanizeJsonLogic({ and: [{ ">": [{ var: "amount" }, 100] }, { "==": [{ var: "region" }, "EU"] }] });
// '(amount > 100) AND (region == "EU")'

humanizeJsonLogic deckt die gängigen Operatoren ab (var, Vergleiche, and/or/!, Arithmetik, in, if-Ketten) und fällt für alles andere auf eine generische funktionsname(arg, ...)-Notation bzw., wenn die Struktur gar keinem Ein-Operator-JSONLogic-Knoten entspricht, auf rohes JSON.stringify zurück — kein Rateversuch, wo die Bedeutung unklar wäre.

EGatewayType.DISCRIMINATOR hat kein BPMN-Gegenstück (WP9 aus der Workflow-Patterns-Literatur, kein Teil des BPMN-Standards). Wird als Exclusive Gateway exportiert, mit einer erklärenden <bpmn:documentation>, dass die tatsächliche Semantik (erstes ankommendes Token gewinnt, alle anderen werden als redundant behandelt statt gemerged) davon abweicht. Ist originSplitNodeID gesetzt (s. o., "Gateways"), wird der benannte EVENT-Split zusätzlich in einer eigenen <bpmn:documentation> genannt — ebenfalls kein Standard-BPMN-Konzept, aber für einen Menschen am Diagramm relevante Information.

Namen/Beschriftung. IDescribed.name wird als BPMN-name-Attribut exportiert, IDescribed.description (sowie Task-taskType, Kanten-Bedingungen, die Discriminator-Erklärung, …) als <bpmn:documentation> — BPMN erlaubt davon mehrere pro Element, die entsprechend akkumuliert werden statt sich gegenseitig zu überschreiben.

Named message/signal/error/escalation-Deklarationen. Mehrere Event-Knoten mit demselben messageName/signalName/errorCode/escalationCode referenzieren dieselbe, einmalige Top-Level-Deklaration unter <bpmn:definitions> (messageRef/signalRef/errorRef/escalationRef) — genauso, wie ein Modeler das von Hand anlegen würde.

Abhängigkeit: fast-xml-builder (schlanke, reine Build-Bibliothek — kein DOM, keine Parser-Altlasten; von fast-xml-parser selbst als Nachfolger für dessen eigenen, mittlerweile deprecateten XMLBuilder empfohlen).