@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/README.md CHANGED
@@ -45,6 +45,14 @@ human-in-the-loop path), composed with control-flow combinators:
45
45
  - `w.branch(condition, { then, else? })` — a two-way choice on a FEEL boolean.
46
46
  - `w.loop(body)` — a durable loop (back-edge to the loop head).
47
47
  - `w.break()` / `w.continue()` — exit the enclosing loop, or jump back to its head.
48
+ - `w.parallel([blockA, blockB, …])` — a static fork/join: every block runs
49
+ concurrently on its own branch, rejoined by an AND-join (all branches must
50
+ arrive before the flow continues).
51
+ - `w.forEach(collection, itemVar, body, opts?)` — a data-driven fan-out over a
52
+ FEEL `collection`: one child instance of `body` runs per item (the item bound to
53
+ `itemVar`). `{ sequential }` runs children one at a time; `{ outputCollection,
54
+ outputElement }` collects each child's result into a list; `{ completionCondition }`
55
+ completes the body early.
48
56
 
49
57
  ```ts
50
58
  import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
@@ -89,8 +97,10 @@ const convergence = defineFlow("convergence-loop", (w) => {
89
97
  Each combinator compiles to a BPMN primitive the engine already runs: `switch` /
90
98
  `branch` → an exclusive gateway (in-order conditions, first match wins, default =
91
99
  unconditional flow); `loop` → a convergent gateway whose body falls through back to
92
- the head; nodes with multiple incoming flows are an implicit XOR merge. See
93
- **ADR 0047**.
100
+ the head; nodes with multiple incoming flows are an implicit XOR merge; `parallel`
101
+ → a diverging/converging **parallel gateway** pair (AND fork/join); `forEach` → a
102
+ **parallel multi-instance** activity (a single-step body) or an embedded
103
+ multi-instance sub-process (a multi-step body). See **ADR 0047**.
94
104
 
95
105
  #### Typed data envelopes (eject to model-first with contracts intact)
96
106
 
@@ -178,7 +188,7 @@ once.
178
188
  | Export | Purpose |
179
189
  | --- | --- |
180
190
  | `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow — experimental/internal. |
181
- | `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal` + `switch`/`branch`/`loop`/`break`/`continue`, with an optional typed contracts map. |
191
+ | `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal`/`timer` + `switch`/`branch`/`loop`/`break`/`continue` + `parallel`/`forEach`, with an optional typed contracts map. |
182
192
  | `envelope(name, fields)` | A typed data envelope; lifted into the model as a `nano:shape` + `dataEnvelope` wiring. |
183
193
  | `externalJobTypes(flow)` | The job types of a flow's external `task` steps (each overridable per-step via `w.task(name, { jobType })`). |
184
194
  | `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
package/dist/client.d.ts CHANGED
@@ -26,23 +26,23 @@ export interface NanoSdkClient {
26
26
  createDeployment(input: {
27
27
  resources: File[];
28
28
  [k: string]: unknown;
29
- }, options?: unknown): Promise<Record<string, unknown>>;
29
+ }, options?: unknown): Promise<unknown>;
30
30
  createProcessInstance(input: {
31
31
  processDefinitionId: string;
32
32
  variables?: JsonObject;
33
- }, options?: unknown): Promise<Record<string, unknown>>;
33
+ }, options?: unknown): Promise<unknown>;
34
34
  correlateMessage(input: {
35
35
  name: string;
36
36
  correlationKey: string;
37
37
  variables?: JsonObject;
38
- }, options?: unknown): Promise<Record<string, unknown>>;
38
+ }, options?: unknown): Promise<unknown>;
39
39
  getProcessInstance(input: {
40
40
  processInstanceKey: string;
41
41
  }, consistency: {
42
42
  consistency: {
43
43
  waitUpToMs: number;
44
44
  };
45
- }, options?: unknown): Promise<Record<string, unknown>>;
45
+ }, options?: unknown): Promise<unknown>;
46
46
  createJobWorker(cfg: JobWorkerConfig): NanoJobWorker;
47
47
  }
48
48
  /** Config for a nano-sdk job worker (the subset the Worker runtime sets). */
package/dist/client.js CHANGED
@@ -3,11 +3,57 @@
3
3
  // deploys it (with diagram interchange), starts instances, and correlates
4
4
  // signals; the low-level job protocol used by the Worker runtime is the SDK's
5
5
  // own job worker, reached through the exposed `sdk` client.
6
- import { createCamundaClient } from "@nanobpm/nano-sdk";
6
+ import { createCamundaClient, JobActionReceiptSymbol, ProcessDefinitionId, ProcessInstanceKey } from "@nanobpm/nano-sdk";
7
7
  import { declarativeToBpmn, walkNodes } from "./declarative.js";
8
8
  import { imperativeToBpmn } from "./imperative.js";
9
9
  import { layoutBpmn } from "./layout.js";
10
10
  import { assertWorkflowIds, messageName } from "./xml.js";
11
+ function isRecord(v) {
12
+ return typeof v === "object" && v !== null && !Array.isArray(v);
13
+ }
14
+ function errorCause(e) {
15
+ return isRecord(e) ? e.cause : undefined;
16
+ }
17
+ function errorCode(e) {
18
+ return isRecord(e) ? e.code : undefined;
19
+ }
20
+ function errorMessage(e) {
21
+ return isRecord(e) && typeof e.message === "string" ? e.message : String(e);
22
+ }
23
+ function errorStatus(e) {
24
+ if (!isRecord(e))
25
+ return undefined;
26
+ if (typeof e.status === "number")
27
+ return e.status;
28
+ const response = e.response;
29
+ return isRecord(response) && typeof response.status === "number" ? response.status : undefined;
30
+ }
31
+ function isJson(v) {
32
+ if (v === null)
33
+ return true;
34
+ switch (typeof v) {
35
+ case "boolean":
36
+ case "number":
37
+ case "string":
38
+ return true;
39
+ case "object":
40
+ if (Array.isArray(v))
41
+ return v.every(isJson);
42
+ return Object.values(v).every(isJson);
43
+ default:
44
+ return false;
45
+ }
46
+ }
47
+ function toJsonObject(v) {
48
+ if (!isRecord(v))
49
+ throw new Error("SDK response was not an object");
50
+ const result = {};
51
+ for (const [key, value] of Object.entries(v)) {
52
+ if (isJson(value))
53
+ result[key] = value;
54
+ }
55
+ return result;
56
+ }
11
57
  /** Render a workflow (either surface) to its executable BPMN model. */
12
58
  export function toBpmn(wf) {
13
59
  assertWorkflowIds(wf);
@@ -37,8 +83,8 @@ export async function toDeployableBpmn(wf, opts = {}) {
37
83
  // Only fall back for the "optional dep not installed" case (layoutBpmn wraps
38
84
  // it with an ERR_MODULE_NOT_FOUND cause). Any other failure is a real layout
39
85
  // problem and must surface rather than silently deploy an uninspectable model.
40
- const cause = e?.cause;
41
- if (cause?.code !== "ERR_MODULE_NOT_FOUND")
86
+ const cause = errorCause(e);
87
+ if (errorCode(cause) !== "ERR_MODULE_NOT_FOUND")
42
88
  throw e;
43
89
  if (!warnedNoLayout) {
44
90
  warnedNoLayout = true;
@@ -65,9 +111,8 @@ export class WorkflowError extends Error {
65
111
  function asWorkflowError(e, what) {
66
112
  if (e instanceof WorkflowError)
67
113
  return e;
68
- const err = e;
69
- const status = err?.status ?? err?.response?.status;
70
- return new WorkflowError(`${what} failed: ${err?.message ?? String(e)}`, status);
114
+ const status = errorStatus(e);
115
+ return new WorkflowError(`${what} failed: ${errorMessage(e)}`, status);
71
116
  }
72
117
  export class WorkflowClient {
73
118
  /** The underlying nano-sdk client, exposed so the Worker runtime and app
@@ -83,13 +128,42 @@ export class WorkflowClient {
83
128
  }
84
129
  if (!opts?.baseUrl)
85
130
  throw new Error("WorkflowClient needs a baseUrl or a client");
86
- this.sdk = createCamundaClient({
131
+ const raw = createCamundaClient({
87
132
  config: {
88
133
  CAMUNDA_REST_ADDRESS: opts.baseUrl.replace(/\/+$/, ""),
89
134
  ...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
90
135
  CAMUNDA_TRANSPORT: opts.transport ?? "auto",
91
136
  },
92
137
  });
138
+ this.sdk = {
139
+ createDeployment: (input) => raw.createDeployment(input),
140
+ createProcessInstance: (input) => raw.createProcessInstance({
141
+ ...input,
142
+ processDefinitionId: ProcessDefinitionId.assumeExists(input.processDefinitionId),
143
+ }),
144
+ correlateMessage: (input) => raw.correlateMessage(input),
145
+ getProcessInstance: (input, consistency) => raw.getProcessInstance({
146
+ processInstanceKey: ProcessInstanceKey.assumeExists(input.processInstanceKey),
147
+ }, consistency),
148
+ createJobWorker: (cfg) => {
149
+ const worker = raw.createJobWorker({
150
+ ...cfg,
151
+ jobHandler: async (job) => {
152
+ await cfg.jobHandler(job);
153
+ return JobActionReceiptSymbol;
154
+ },
155
+ });
156
+ return {
157
+ start: () => {
158
+ void worker.start();
159
+ },
160
+ stop: () => worker.stop(),
161
+ stopGracefully: async (opts) => {
162
+ await worker.stopGracefully(opts);
163
+ },
164
+ };
165
+ },
166
+ };
93
167
  }
94
168
  /**
95
169
  * Deploy a workflow's derived BPMN model. The deployed model includes
@@ -102,7 +176,7 @@ export class WorkflowClient {
102
176
  const xml = await toDeployableBpmn(wf, opts);
103
177
  const file = new File([xml], `${wf.id}.bpmn`, { type: "text/xml" });
104
178
  try {
105
- return (await this.sdk.createDeployment({ resources: [file] }));
179
+ return toJsonObject(await this.sdk.createDeployment({ resources: [file] }));
106
180
  }
107
181
  catch (e) {
108
182
  throw asWorkflowError(e, "deploy");
@@ -116,7 +190,7 @@ export class WorkflowClient {
116
190
  async start(wf, input = {}) {
117
191
  const variables = wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
118
192
  try {
119
- return (await this.sdk.createProcessInstance({
193
+ return toJsonObject(await this.sdk.createProcessInstance({
120
194
  processDefinitionId: wf.id,
121
195
  variables,
122
196
  }));
@@ -140,7 +214,7 @@ export class WorkflowClient {
140
214
  throw new WorkflowError(`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"}`);
141
215
  }
142
216
  try {
143
- return (await this.sdk.correlateMessage({
217
+ return toJsonObject(await this.sdk.correlateMessage({
144
218
  name: messageName(flow.id, signalName),
145
219
  correlationKey,
146
220
  variables,
@@ -154,7 +228,7 @@ export class WorkflowClient {
154
228
  * zero-wait consistency; returns null when the instance is not (yet) visible. */
155
229
  async getInstance(processInstanceKey) {
156
230
  try {
157
- return (await this.sdk.getProcessInstance({ processInstanceKey }, { consistency: { waitUpToMs: 0 } }));
231
+ return toJsonObject(await this.sdk.getProcessInstance({ processInstanceKey }, { consistency: { waitUpToMs: 0 } }));
158
232
  }
159
233
  catch {
160
234
  return null;
@@ -3,18 +3,18 @@ import type { Envelope } from "./envelope.js";
3
3
  /** The TS payload type of a contract's input envelope (untyped fallback). */
4
4
  type InPayload<Ct> = Ct extends {
5
5
  in: Envelope;
6
- } ? Ct["in"]["type"] : JsonObject;
6
+ } ? Ct["in"]["type"] & JsonObject : JsonObject;
7
7
  /** The TS payload type of a contract's output envelope (untyped fallback). */
8
8
  type OutPayload<Ct> = Ct extends {
9
9
  out: Envelope;
10
- } ? Ct["out"]["type"] : JsonObject;
10
+ } ? Ct["out"]["type"] & JsonObject : JsonObject;
11
11
  /** The input payload type of step `K` under contracts `C`. */
12
12
  type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
13
13
  /** The output payload type of step `K` under contracts `C`. */
14
14
  type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : JsonObject;
15
15
  /** A typed handler for a `run` step: its job variables and result are resolved
16
16
  * from the flow contracts by the step name. */
17
- type TypedHandler<V, R> = (job: {
17
+ type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
18
18
  jobKey: string;
19
19
  processInstanceKey: string;
20
20
  elementId: string;
@@ -22,8 +22,19 @@ type TypedHandler<V, R> = (job: {
22
22
  variables: V;
23
23
  }) => Promise<R | void> | R | void;
24
24
  /** A block: the callback that populates a nested body (a case, arm, or loop). */
25
- type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
26
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
25
+ type Block<C extends object> = (b: FlowBuilder<C>) => void;
26
+ /** The typed builder for a flow body.
27
+ *
28
+ * `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
29
+ * default is `object`, NOT `Record<string, never>`: with a `never`-valued record
30
+ * `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
31
+ * `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
32
+ * `never` — typing an untyped flow's `job.variables` as `never` and forcing its
33
+ * handler return to `void` (so no untyped flow that returns data compiles). With
34
+ * `object`, `keyof C` is `never`, so every step falls back to the intended
35
+ * `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
36
+ * the stricter contracts constraint; this is only the untyped-builder default. */
37
+ export interface FlowBuilder<C extends object = object> {
27
38
  /**
28
39
  * A durable activity served by a worker THIS program hosts (a BPMN service
29
40
  * task; the handler runs in the in-process `Worker`). If `name` is a key in
@@ -109,6 +120,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
109
120
  * the loop run once `break()` is reached.
110
121
  */
111
122
  loop(body: Block<C>): FlowBuilder<C>;
123
+ /**
124
+ * A static parallel fork/join (a pair of BPMN parallel gateways). Every block
125
+ * runs concurrently on its own branch; control continues past `parallel` only
126
+ * once ALL branches have reached the joining gateway (AND-join). Pass at least
127
+ * two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
128
+ * conditional — all of them run.
129
+ */
130
+ parallel(branches: Block<C>[]): FlowBuilder<C>;
131
+ /**
132
+ * A data-driven fan-out over a runtime collection (a BPMN parallel
133
+ * multi-instance activity). `collection` is a FEEL expression (no leading `=`)
134
+ * evaluating to a list; one child instance of `body` runs per item, with the
135
+ * item bound to the `itemVar` variable in the child's scope. A single-activity
136
+ * body attaches the multi-instance characteristics to that activity; a
137
+ * multi-step body is wrapped in an embedded multi-instance sub-process. Options:
138
+ * - `sequential` — run children one at a time (a sequential MI) instead of all
139
+ * at once (the default: a parallel MI).
140
+ * - `outputCollection` / `outputElement` — collect each child's `outputElement`
141
+ * (a FEEL expression, no leading `=`) into the `outputCollection` list
142
+ * variable, in item order.
143
+ * - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
144
+ * after a child completes, the remaining children are cancelled and the body
145
+ * completes early.
146
+ */
147
+ forEach(collection: string, itemVar: string, body: Block<C>, opts?: {
148
+ sequential?: boolean;
149
+ outputCollection?: string;
150
+ outputElement?: string;
151
+ completionCondition?: string;
152
+ }): FlowBuilder<C>;
112
153
  /** Exit the enclosing loop (routes to whatever follows it). Only valid inside
113
154
  * a `loop`. */
114
155
  break(): FlowBuilder<C>;