@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 +13 -3
- package/dist/client.d.ts +4 -4
- package/dist/client.js +85 -11
- package/dist/declarative.d.ts +46 -5
- package/dist/declarative.js +280 -28
- package/dist/envelope.js +10 -3
- package/dist/imperative.js +19 -8
- package/dist/layout.js +4 -1
- package/dist/types.d.ts +21 -1
- package/dist/worker.js +18 -8
- package/package.json +3 -3
- package/src/client.ts +102 -20
- package/src/declarative.ts +374 -52
- package/src/envelope.ts +10 -3
- package/src/imperative.ts +20 -9
- package/src/layout.ts +5 -1
- package/src/types.ts +27 -4
- package/src/worker.ts +19 -7
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
|
|
93
|
-
**
|
|
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<
|
|
29
|
+
}, options?: unknown): Promise<unknown>;
|
|
30
30
|
createProcessInstance(input: {
|
|
31
31
|
processDefinitionId: string;
|
|
32
32
|
variables?: JsonObject;
|
|
33
|
-
}, options?: unknown): Promise<
|
|
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<
|
|
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<
|
|
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
|
|
41
|
-
if (cause
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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;
|
package/dist/declarative.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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>;
|