@nanobpm/workflow 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -21,8 +21,9 @@ export interface Job<V extends JsonObject = JsonObject> {
21
21
  // --- Declarative surface (Strategy A: compile a step tree to a BPMN model) ----
22
22
  //
23
23
  // A flow is a TREE of nodes, not a flat list: leaf activities (`run`/`task`/
24
- // `signal`) plus structural combinators (`switch`/`branch`/`loop`) that compile
25
- // to real XOR gateways and back-edges. See declarative.ts for the compiler.
24
+ // `signal`) plus structural combinators (`switch`/`branch`/`loop` — XOR gateways
25
+ // + back-edges — and `parallel`/`forEach` — parallel gateways + multi-instance)
26
+ // that compile to real BPMN the engine runs. See declarative.ts for the compiler.
26
27
 
27
28
  /** The input/output data envelopes lifted onto an activity node. */
28
29
  export interface NodeEnvelopes {
@@ -53,11 +54,50 @@ export type FlowNode =
53
54
  | { kind: "run"; name: string; envelopes?: NodeEnvelopes }
54
55
  | { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
55
56
  | { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
57
+ | ({ kind: "timer"; name: string } & TimerAt)
56
58
  | { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
57
59
  | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
58
60
  | { kind: "loop"; body: FlowNode[] }
59
61
  | { kind: "break" }
60
- | { kind: "continue" };
62
+ | { kind: "continue" }
63
+ | { kind: "parallel"; branches: FlowNode[][] }
64
+ | {
65
+ kind: "forEach";
66
+ /** FEEL expression evaluating to the list to fan out over. */
67
+ collection: string;
68
+ /** Variable each item is bound to in the child's scope (`inputElement`). */
69
+ itemVar: string;
70
+ body: FlowNode[];
71
+ /** Run children one at a time (sequential MI) instead of all at once. */
72
+ sequential?: boolean;
73
+ /** List variable that each child's `outputElement` is collected into. */
74
+ outputCollection?: string;
75
+ /** FEEL expression producing each child's contribution to `outputCollection`. */
76
+ outputElement?: string;
77
+ /** FEEL boolean that, once true, completes the body early. */
78
+ completionCondition?: string;
79
+ };
80
+
81
+ /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
82
+ * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
83
+ * Encoded as an XOR so downstream consumers can rely on exactly-one. */
84
+ export type TimerAt =
85
+ | { after: string; at?: never }
86
+ | { at: string; after?: never };
87
+
88
+ /** A flow's start-timer: the plain none start event becomes a durable timer
89
+ * start the engine fires on schedule. Exactly one of:
90
+ * - `cycle` — a recurring ISO-8601 interval (`R/PT1H`, `R5/PT30M`) or bare
91
+ * duration; the engine re-fires on each period. This is the model-native,
92
+ * durable, single-fire-per-cluster replacement for an app-side cron.
93
+ * - `after` — a one-shot ISO-8601 delay (`PT10S`) measured from deployment.
94
+ * - `at` — a one-shot absolute instant (an ISO-8601 date-time, or a FEEL
95
+ * `=` expression).
96
+ * Encoded as an XOR so the exactly-one invariant holds at the type level. */
97
+ export type TimerStart =
98
+ | { cycle: string; after?: never; at?: never }
99
+ | { after: string; cycle?: never; at?: never }
100
+ | { at: string; cycle?: never; after?: never };
61
101
 
62
102
  /** One case of a `switch`: routed when `subject = value` (FEEL equality). */
63
103
  export interface SwitchCase {
@@ -75,6 +115,9 @@ export interface DeclarativeFlow {
75
115
  /** The flow's node tree (top-level sequence). */
76
116
  steps: FlowNode[];
77
117
  handlers: Record<string, StepHandler>;
118
+ /** When set, the flow's start event is a durable timer start (see
119
+ * {@link TimerStart}) rather than a plain none start. */
120
+ startTimer?: TimerStart;
78
121
  }
79
122
 
80
123
  // --- Imperative surface (Strategy B: a replayed orchestration function) -------
package/src/worker.ts CHANGED
@@ -112,8 +112,9 @@ export class Worker {
112
112
  } else {
113
113
  // Walk the flow tree; only `run` steps are hosted locally. `signal`
114
114
  // (message catch) and `task` (external worker) contribute no in-process
115
- // route, and structural combinators (`switch`/`branch`/`loop`) are pure
116
- // routing with no job of their own.
115
+ // route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
116
+ // `forEach`) are pure routing with no job of their own — but the `run`
117
+ // steps NESTED inside them are hosted, since `walkNodes` recurses.
117
118
  walkNodes(wf.steps, (s) => {
118
119
  if (s.kind !== "run") return;
119
120
  const handler = wf.handlers[s.name];
package/src/xml.ts CHANGED
@@ -40,6 +40,68 @@ export function assertJobType(kind: string, value: string): void {
40
40
  }
41
41
  }
42
42
 
43
+ // --- Timer definitions (BPMN timerEventDefinition bodies) --------------------
44
+ //
45
+ // The engine (engine-core/src/bpmn.rs) accepts a nested `timeDuration`,
46
+ // `timeCycle`, or `timeDate` as either a static ISO-8601 literal or a FEEL
47
+ // expression (a leading `=`). We validate the literal forms at authoring time so
48
+ // a malformed timer fails fast in the DSL rather than at deploy.
49
+
50
+ /** An ISO-8601 duration with integer week/day/hour/minute/second components
51
+ * (no years/months — ambiguous length — matching the engine's parser). */
52
+ const ISO_DURATION = /^P(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/;
53
+ function isIsoDuration(v: string): boolean {
54
+ if (!ISO_DURATION.test(v) || v === "P" || v === "PT" || v.endsWith("T")) return false;
55
+ return /\d/.test(v);
56
+ }
57
+
58
+ /** A recurring ISO-8601 interval: an optional `R[n]/` repeat prefix + a
59
+ * duration, or a bare duration (the engine repeats it unboundedly). */
60
+ function isIsoCycle(v: string): boolean {
61
+ const slash = v.indexOf("/");
62
+ if (slash < 0) return isIsoDuration(v);
63
+ const repeat = v.slice(0, slash);
64
+ const interval = v.slice(slash + 1);
65
+ return /^R\d*$/.test(repeat) && isIsoDuration(interval);
66
+ }
67
+
68
+ /** An ISO-8601 date-time instant (a `timeDate`). A `timeDate` is an absolute
69
+ * instant, so a zone designator (`Z` or an explicit `±HH:MM` offset) is
70
+ * required — a bare local datetime like `2026-01-01T09:00:00` is ambiguous. */
71
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
72
+
73
+ /** A FEEL expression body (a leading `=`, non-empty). */
74
+ function isFeel(v: string): boolean {
75
+ return v.startsWith("=") && v.length > 1;
76
+ }
77
+
78
+ export function assertTimerDuration(kind: string, value: string): void {
79
+ const v = typeof value === "string" ? value.trim() : "";
80
+ if (v === "" || (!isFeel(v) && !isIsoDuration(v))) {
81
+ throw new Error(
82
+ `${kind} "${value}" is not an ISO-8601 duration (e.g. PT1M30S, P1DT6H) or a FEEL expression (=...)`,
83
+ );
84
+ }
85
+ }
86
+
87
+ export function assertTimerCycle(kind: string, value: string): void {
88
+ const v = typeof value === "string" ? value.trim() : "";
89
+ if (v === "" || (!isFeel(v) && !isIsoCycle(v))) {
90
+ throw new Error(
91
+ `${kind} "${value}" is not an ISO-8601 repeating interval (e.g. R/PT1H, R5/PT30M), a bare duration (e.g. PT1H), or a FEEL expression (=...)`,
92
+ );
93
+ }
94
+ }
95
+
96
+ export function assertTimerDate(kind: string, value: string): void {
97
+ const v = typeof value === "string" ? value.trim() : "";
98
+ if (v === "" || (!isFeel(v) && !ISO_DATE.test(v))) {
99
+ throw new Error(
100
+ `${kind} "${value}" is not an ISO-8601 instant (e.g. 2026-01-01T09:00:00Z) or a FEEL expression (=...)`,
101
+ );
102
+ }
103
+ }
104
+
43
105
  export function assertWorkflowIds(wf: Workflow): void {
44
106
  assertIdent("workflow id", wf.id);
45
107
  if (wf.kind === "declarative") {
@@ -54,6 +116,9 @@ function assertNodeNames(nodes: FlowNode[]): void {
54
116
  case "signal":
55
117
  assertIdent("step name", node.name);
56
118
  break;
119
+ case "timer":
120
+ assertIdent("step name", node.name);
121
+ break;
57
122
  case "task":
58
123
  assertIdent("step name", node.name);
59
124
  if (node.jobType !== undefined) assertJobType("job type", node.jobType);