@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.
Keywords
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 inengineModel.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(siehepackage.json,engines) — die Bibliothek nutzt neuere Node-APIs (u. a.structuredClone,node:timers/promises), die auf älteren Runtimes nicht laufen. - Reines ESM (
"type": "module"inpackage.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, peridindiziert) und Kanten (edges). Wird von Client, Server und Validator-Eingabe gleichermaßen verwendet.IWorkflowDefinition— die validierte Form: einIWorkflowDefinitionDraftplusderived: IWorkflowDefinitionDerivedData, einmalig vonvalidateWorkflowDefinitionberechnet. Einmal veröffentlicht als unveränderlich behandelt. Das, womitIWorkflowDefinitionRegistryund 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ßendemIGatewayXORSplitNodedie Kante, die die Schleife tatsächlich schließt.IWorkflowInstance— der laufende Zustand eines gestarteten Prozesses:state(EWorkflowState),context(fachliche Prozessvariablen),tokens(alleITokens der Instanz, aktiv und inaktiv, peridindiziert),engineData(opake, JSON-serialisierbare Ablage für Engine-internes Bookkeeping),externalData(opake, JSON-serialisierbare, perIToken.idindizierte Ablage für das, was einIEventNodeLifecycleHookbeionEventNodeEnteredzurückgibt — anders alsengineDataKEIN Engine-internes Bookkeeping, die Engine merged/entfernt nur mechanisch, siehe "Event-Knoten-Lifecycle-Hook" unten),error(gesetzt beiFAILED),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:
- Diskriminatoren (
nodeType,gatewayType,mode,eventType) — sitzen immer auf oberster Ebene. - 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. - Fachliche Eigenwerte (
messageName,errorCode,timerCycle,triggerType,taskType, …) — leben in einem dediziertenI<Type>Config-Interface, angebunden überIConfigurable<TConfig>(config?: TConfigbzw.config: TConfig, wenn zwingend erforderlich). - Beschriftung (
name,description) — überIDescribedgemixt inIWorkflowNode,IWorkflowEdgeundIWorkflowDefinitionDraft. 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 überITokenStrategy.cancelretiriert. Welche aktuell geparkten Tokens zur Kohorte gehören, wird strukturell über dieparentTokenID-Kette bis zum passenden Split (node.splitNodeID) ermittelt (findArrivedForkCohort/findForkOriginTokenID,engine.ts) — NICHT übertoken.siblingTokenIDs, das beiNewTokenPerIterationStrategymit 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 übernode.splitNodeIDals 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 überITokenStrategy.cancelabgebrochen. - Discriminator-Join: Beim ersten Ankommen wird die Geschwister-Kohorte abgebrochen, unabhängig davon, wo sie sich gerade befinden. Ohne
originSplitNodeID(Standardfall) direkt übertoken.siblingTokenIDs. Mit gesetztemoriginSplitNodeIDstattdessen strukturell über dieparentTokenID-Kette bis zu diesem Split ermittelt (analogfindArrivedForkCohort, aber ohne dessen Einschränkung auf am selben Knoten geparkte Tokens) — nötig, weiltoken.siblingTokenIDsbeiNewTokenPerIterationStrategymit 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
IEventNodeein eigenes, aktives Watcher-Token, das am jeweiligen Event-Knoten parkt. Das Host-Token bleibt dabei aktiv und unverändert weiterlaufend (kein Fork imITokenStrategy.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 wiederkehrendentimerCycle); 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— anITaskDispatcher.dispatchübergeben.payloadVariables(optional) — Liste von Context-Keys; deren Werte werden alspayloadandispatchübergeben (fehlt das Feld: leerespayload).dispatcherhält den vollständigencontextunabhängig davon zusätzlich als eigenes Argument, nebeninstanceID/tokenID(siehe unten).resultVariable(optional) — Context-Key, unter demonTaskCompletedsresultabgelegt 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.versionist 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:
- Referenzielle Integrität — mindestens ein Start- und ein End-Knoten (
NO_START_NODE/NO_END_NODE); jeder Knoten hat als eigenesid-Feld genau den Key, unter dem er innodessteht (NODE_ID_KEY_MISMATCH); keine dangling edges (DANGLING_EDGE_SOURCE/DANGLING_EDGE_DESTINATION); keine doppelt vergebeneedge.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-originSplitNodeIDreferenziert einen echten EVENT-Split (einseitig, keine Gegenseitigkeitsprüfung nötig — s. o., "Gateways"). Config-Shape-Prüfungen (siehe unten) laufen in derselben Runde mit. - Vorwärts-Erreichbarkeit ab jedem Start-Knoten (
UNREACHABLE_FROM_START) — ein Boundary-Event (attachedToNodeIDgesetzt) gilt dabei implizit als erreichbar, sobald sein Host-Task erreichbar ist, auch ganz ohne eigene eingehende Kante (siehe "Platzierung: Inline vs. Boundary" oben). - 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 peroriginSplitNodeIDbestä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);maxLoopIterationsverlangt eineisLoopEscalation-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). - Rückwärts-Erreichbarkeit zu jedem End-Knoten (
CANNOT_REACH_END). - Default-Kante für jeden XOR-/OR-Split (
SPLIT_WITHOUT_DEFAULT_EDGE). - 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 brauchtsignalName; TIMER braucht genau eines vontimerDate/timerCycle. - Event-Knoten: MESSAGE braucht
messageName; SIGNAL brauchtsignalName; TIMER braucht genau eines vontimerDate/timerDuration/timerCycle; ERROR brauchterrorCode; ESCALATION brauchtescalationCode.
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 dieidgegenü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 wienextIteration.
Zwei mitgelieferte Implementierungen:
ReuseTokenStrategy— behält bei einer Schleifen-Iteration dieselbe Token-id.NewTokenPerIterationStrategy(Default inWorkflowEngineBuilder) — vergibt bei jeder Schleifen-Iteration eine neueid, mitparentTokenIDauf 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), ruftITaskDispatcher.dispatch, ruftIEngineNotifier.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
startselbst) 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 vonInProcessTimerSchedulersonceRetryPolicyverwendet, siehe "Timer Scheduler" unten) — steuert hier, ob/wie oft nach einemVersionConflictErrorvonIStateStorage.saveneu geladen, neu berechnet und erneut gespeichert wird (ExponentialBackoffRetryPolicy, Default: 5 Versuche, exponentiell wachsende, gejitterte Pausen;FixedDelayRetryPolicyals einfachere Alternative mit fester Pause, Default 3 Versuche à 1000ms). Gilt für die vier Trigger-Methoden;starthat keine Retry-Schleife (siehe unten, warum). Konstruktor-Optionen vonExponentialBackoffRetryPolicy(IExponentialBackoffRetryPolicyOptions, alle optional):maxAttempts(Default 5),baseDelayMs(Default 50),maxDelayMs(Default 2000),jitter(Defaulttrue). Konstruktor-Optionen vonFixedDelayRetryPolicy(IFixedDelayRetryPolicyOptions, alle optional):maxAttempts(Default 3),delayMs(Default 1000).EEngineFaultType—UNEXPECTED_TOKEN_STATE,CONDITION_EVALUATION_FAILED,INVARIANT_VIOLATION,UNKNOWN. Bei einem nicht-retrybaren Fehler (instartebenso wie in den drei Trigger-Methoden):IEngineErrorHandler.onFaultwird best-effort aufgerufen, einFAULT-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 alsEWorkflowState.FAILEDmitIWorkflowErrorgespeichert — 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 vonIEngineErrorHandler.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ährendstartselbst läuft, wird nie persistiert zurückgegeben — der erste tatsächlich gespeicherte Zustand ist bereitsWAITING/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 jedemITaskDispatcher.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 neuenEWorkflowState(WAITING/SUCCEEDED/FAILED). Payload:IEngineBusInstanceTransitionedPayload { instanceID, state, targetVersion }.onInstanceCompleted— zusätzlich zuonInstanceTransitioned, nur wennstateSUCCEEDEDoderFAILEDist (nieWAITING) — die Instanz wird nie wieder einen Zustand wechseln. Gleiches Payload wieonInstanceTransitioned. Feuert auch fürFAILED(inkl. dem Fault-Pfad instart), nicht nur fürSUCCEEDED.
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 inIWorkflowInstance.externalData[tokenID]und wird mit demselbensave()-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 einemreason(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), oderFAILED(die Instanz ist inFAILEDübergegangen, während dieses Token noch hier geparkt war — siehe direkt unten). Bekommt das zurück, wasonEventNodeEnteredzuvor für dieses Token gespeichert hat (ctx.data). Was auch immeronEventNodeLeftselbst tut — der Eintrag inexternalDatawird 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 (VersionConflictErrorbei Versions-Mismatch,version + 1bei 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 zuload:add(definition),remove(definitionID),has(definitionID),list().toJSON()/static fromJSON(data)für Snapshot/Restore.InMemEventBus(IEventBus) — In-Process-Publish/Subscribe. Zusätzlich zuemit: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 brichtemit()nie ab — Fehler gehen an den optionalen Konstruktor-ParameteronSubscriberError?(eventName, err), Default: stillschweigend verworfen. Typischerweise erreicht übercreateEventBusEngineNotifier(siehe oben), nicht direkt von der Engine verwendet.NodeEventEmitterEventBus(IEventBus) — zweite, gleichwertige Referenzimplementierung, auf Basis vonnode:events'EventEmitterstatt eigenerMap/Set-Buchhaltung für den benannten Teil. Identische öffentliche Oberfläche und identisches Verhalten wieInMemEventBus(emit/on/onAny/waitFor, gleicheronSubscriberError-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). Erneutesschedule()für dasselbe Paar ersetzt den vorherigen Timer, statt einen zweiten, konkurrierenden zu starten. Wirft synchron, wenn keines der drei Felder gesetzt ist, odertimerCyclenicht 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 aufjetzt + Intervall.1— genau eine Nachhol-Feuerung, danach wird der Rest des Rückstands übersprungen.N— bis zuNNachhol-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 HerunterfahrenBaut 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).
