@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/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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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 = (
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
25
|
-
//
|
|
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
|
|
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
|
|
105
|
-
const journal = (job.variables.journal
|
|
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)
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
140
|
+
handle: async (job) => {
|
|
141
|
+
const variables: JsonObject = (await handler(job)) ?? {};
|
|
142
|
+
return { variables };
|
|
143
|
+
},
|
|
132
144
|
});
|
|
133
145
|
});
|
|
134
146
|
}
|