@nanobpm/workflow 0.7.0 → 0.8.1

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/envelope.ts CHANGED
@@ -93,6 +93,15 @@ const SCALARS: ReadonlySet<string> = new Set([
93
93
  "datetime",
94
94
  ]);
95
95
 
96
+ class EnvelopeDef<S extends Record<string, FieldSpec>> implements Envelope<S> {
97
+ readonly type!: EnvelopeType<S>;
98
+
99
+ constructor(
100
+ readonly name: string,
101
+ readonly fields: EnvelopeField[],
102
+ ) {}
103
+ }
104
+
96
105
  function normaliseField(name: string, spec: FieldSpec): EnvelopeField {
97
106
  if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
98
107
  throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
@@ -124,7 +133,5 @@ export function envelope<const S extends Record<string, FieldSpec>>(
124
133
  const list = Object.entries(fields).map(([k, v]) => normaliseField(k, v));
125
134
  if (list.length === 0) throw new Error(`envelope "${name}" declares no fields`);
126
135
  for (const f of list) assertIdent("envelope field name", f.name);
127
- // `type` is phantom: never read at runtime; the cast keeps the value shape
128
- // minimal while the declared type carries the inference.
129
- return { name, fields: list, type: undefined as unknown as EnvelopeType<S> };
136
+ return new EnvelopeDef(name, list);
130
137
  }
package/src/imperative.ts CHANGED
@@ -64,8 +64,19 @@ export function imperativeToBpmn(wf: ImperativeWorkflow): string {
64
64
  export type Journal = Record<string, Json>;
65
65
 
66
66
  const SUSPEND = Symbol("nanobpm.workflow.suspend");
67
- interface SuspendCarrier {
68
- [SUSPEND]?: { key: string; result: Json };
67
+
68
+ class SuspendError extends Error {
69
+ readonly [SUSPEND]: { key: string; result: Json };
70
+
71
+ constructor(key: string, result: Json) {
72
+ super("nanobpm.workflow suspend");
73
+ this[SUSPEND] = { key, result };
74
+ }
75
+ }
76
+
77
+ function assertReplayValue<T extends Json>(_value: Json, _name: string): asserts _value is T {
78
+ // The journal value was recorded from the same ordinal/name run call on an
79
+ // earlier pass; TypeScript cannot express that cross-replay invariant.
69
80
  }
70
81
 
71
82
  /** Outcome of a single replay pass. */
@@ -92,20 +103,20 @@ export async function replayOnce(
92
103
  async run<T extends Json = Json>(name: string, fn: () => Promise<T> | T): Promise<T> {
93
104
  const key = `${++ordinal}:${name}`;
94
105
  if (Object.prototype.hasOwnProperty.call(journal, key)) {
95
- return journal[key] as T; // replay: return recorded value, no side effect
106
+ const value = journal[key];
107
+ assertReplayValue<T>(value, name);
108
+ return value; // replay: return recorded value, no side effect
96
109
  }
97
- const result = ((await fn()) ?? null) as T; // frontier: the ONE real side effect this turn
98
- const carrier = new Error("nanobpm.workflow suspend") as Error & SuspendCarrier;
99
- carrier[SUSPEND] = { key, result };
100
- throw carrier;
110
+ const result = (await fn()) ?? null; // frontier: the ONE real side effect this turn
111
+ assertReplayValue<T>(result, name);
112
+ throw new SuspendError(key, result);
101
113
  },
102
114
  };
103
115
  try {
104
116
  await wf.orchestrate(ctx);
105
117
  return { done: true };
106
118
  } catch (e) {
107
- const s = (e as SuspendCarrier)[SUSPEND];
108
- if (s) return { done: false, frontier: s };
119
+ if (e instanceof SuspendError) return { done: false, frontier: e[SUSPEND] };
109
120
  throw e; // a genuine error in the orchestration or a handler
110
121
  }
111
122
  }
package/src/layout.ts CHANGED
@@ -19,6 +19,10 @@
19
19
  import type { DeclarativeFlow } from "./types.js";
20
20
  import { declarativeToBpmn } from "./declarative.js";
21
21
 
22
+ function isRecord(v: unknown): v is Record<string, unknown> {
23
+ return typeof v === "object" && v !== null && !Array.isArray(v);
24
+ }
25
+
22
26
  /**
23
27
  * Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
24
28
  * `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
@@ -38,7 +42,7 @@ export async function layoutBpmn(bpmnXml: string): Promise<string> {
38
42
  // genuinely cannot be resolved. A different failure (the package is present
39
43
  // but fails to load — syntax, a broken transitive dep, a runtime throw at
40
44
  // import time) must surface as-is so it isn't masked as a missing dependency.
41
- if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ERR_MODULE_NOT_FOUND") {
45
+ if (!isRecord(cause) || cause.code !== "ERR_MODULE_NOT_FOUND") {
42
46
  throw cause;
43
47
  }
44
48
  throw new Error(
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 {
@@ -44,7 +45,12 @@ export interface StepContract {
44
45
  export type FlowContracts = Record<string, StepContract>;
45
46
 
46
47
  /** Handler for a declarative `run` step: does real work, returns variables. */
47
- export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
48
+ export type StepHandler<
49
+ V extends JsonObject = JsonObject,
50
+ R extends JsonObject = JsonObject,
51
+ > = {
52
+ bivarianceHack(job: Job<V>): Promise<R | void> | R | void;
53
+ }["bivarianceHack"];
48
54
 
49
55
  /** A node in a declarative flow tree. Leaf activities carry optional data
50
56
  * envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
@@ -58,7 +64,24 @@ export type FlowNode =
58
64
  | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
59
65
  | { kind: "loop"; body: FlowNode[] }
60
66
  | { kind: "break" }
61
- | { kind: "continue" };
67
+ | { kind: "continue" }
68
+ | { kind: "parallel"; branches: FlowNode[][] }
69
+ | {
70
+ kind: "forEach";
71
+ /** FEEL expression evaluating to the list to fan out over. */
72
+ collection: string;
73
+ /** Variable each item is bound to in the child's scope (`inputElement`). */
74
+ itemVar: string;
75
+ body: FlowNode[];
76
+ /** Run children one at a time (sequential MI) instead of all at once. */
77
+ sequential?: boolean;
78
+ /** List variable that each child's `outputElement` is collected into. */
79
+ outputCollection?: string;
80
+ /** FEEL expression producing each child's contribution to `outputCollection`. */
81
+ outputElement?: string;
82
+ /** FEEL boolean that, once true, completes the body early. */
83
+ completionCondition?: string;
84
+ };
62
85
 
63
86
  /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
64
87
  * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
package/src/worker.ts CHANGED
@@ -70,6 +70,10 @@ export type WorkerOptions =
70
70
 
71
71
  type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
72
72
 
73
+ function isJsonObject(v: unknown): v is JsonObject {
74
+ return typeof v === "object" && v !== null && !Array.isArray(v);
75
+ }
76
+
73
77
  export class Worker {
74
78
  private readonly client: WorkflowClient;
75
79
  private readonly name: string;
@@ -101,19 +105,24 @@ export class Worker {
101
105
  this.addRoute(wf.orchestrateType, {
102
106
  workflowId: wf.id,
103
107
  handle: async (job) => {
104
- const input = (job.variables.input as JsonObject) ?? {};
105
- const journal = (job.variables.journal as JsonObject) ?? {};
108
+ const input = isJsonObject(job.variables.input) ? job.variables.input : {};
109
+ const journal = isJsonObject(job.variables.journal) ? job.variables.journal : {};
106
110
  const step = await replayOnce(wf, input, journal);
107
- if (step.done) return { variables: { wfDone: true } as JsonObject, step: "__done" };
111
+ if (step.done) {
112
+ const variables: JsonObject = { wfDone: true };
113
+ return { variables, step: "__done" };
114
+ }
108
115
  const next = { ...journal, [step.frontier.key]: step.frontier.result };
109
- return { variables: { journal: next, wfDone: false } as JsonObject, step: step.frontier.key };
116
+ const variables: JsonObject = { journal: next, wfDone: false };
117
+ return { variables, step: step.frontier.key };
110
118
  },
111
119
  });
112
120
  } else {
113
121
  // Walk the flow tree; only `run` steps are hosted locally. `signal`
114
122
  // (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.
123
+ // route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
124
+ // `forEach`) are pure routing with no job of their own — but the `run`
125
+ // steps NESTED inside them are hosted, since `walkNodes` recurses.
117
126
  walkNodes(wf.steps, (s) => {
118
127
  if (s.kind !== "run") return;
119
128
  const handler = wf.handlers[s.name];
@@ -128,7 +137,10 @@ export class Worker {
128
137
  }
129
138
  this.addRoute(jobType(wf.id, s.name), {
130
139
  workflowId: wf.id,
131
- handle: async (job) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
140
+ handle: async (job) => {
141
+ const variables: JsonObject = (await handler(job)) ?? {};
142
+ return { variables };
143
+ },
132
144
  });
133
145
  });
134
146
  }