@nanobpm/workflow 0.8.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/dist/client.d.ts +4 -4
- package/dist/client.js +85 -11
- package/dist/declarative.d.ts +3 -3
- package/dist/declarative.js +50 -15
- package/dist/envelope.js +10 -3
- package/dist/imperative.js +19 -8
- package/dist/layout.js +4 -1
- package/dist/types.d.ts +3 -1
- package/dist/worker.js +15 -6
- package/package.json +1 -1
- package/src/client.ts +102 -20
- package/src/declarative.ts +77 -38
- package/src/envelope.ts +10 -3
- package/src/imperative.ts +20 -9
- package/src/layout.ts +5 -1
- package/src/types.ts +6 -1
- package/src/worker.ts +16 -5
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;
|
package/dist/declarative.js
CHANGED
|
@@ -41,6 +41,17 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
|
|
|
41
41
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
42
42
|
* invalid model, so reject them at authoring time. */
|
|
43
43
|
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
44
|
+
const TIMER_START_KEYS = ["cycle", "after", "at"];
|
|
45
|
+
function timerStartValue(spec, key) {
|
|
46
|
+
switch (key) {
|
|
47
|
+
case "cycle":
|
|
48
|
+
return "cycle" in spec ? spec.cycle : undefined;
|
|
49
|
+
case "after":
|
|
50
|
+
return "after" in spec ? spec.after : undefined;
|
|
51
|
+
case "at":
|
|
52
|
+
return "at" in spec ? spec.at : undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
function claimName(ctx, id, name) {
|
|
45
56
|
assertIdent("step name", name);
|
|
46
57
|
if (name === "Start" || name === "End" || name === id) {
|
|
@@ -106,12 +117,12 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
106
117
|
},
|
|
107
118
|
timer(name, opts) {
|
|
108
119
|
claimName(ctx, id, name);
|
|
109
|
-
const hasAfter = typeof opts
|
|
110
|
-
const hasAt = typeof opts
|
|
120
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
121
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
111
122
|
if (hasAfter === hasAt) {
|
|
112
123
|
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
113
124
|
}
|
|
114
|
-
if (
|
|
125
|
+
if ("after" in opts) {
|
|
115
126
|
const after = opts.after.trim();
|
|
116
127
|
assertTimerDuration(`timer("${name}") after`, after);
|
|
117
128
|
out.push({ kind: "timer", name, after });
|
|
@@ -131,16 +142,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
131
142
|
throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
132
143
|
if (ctx.startTimer)
|
|
133
144
|
throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
145
|
+
const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
|
|
146
|
+
if (setCount !== 1) {
|
|
136
147
|
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
137
148
|
}
|
|
138
|
-
if (
|
|
149
|
+
if ("cycle" in spec) {
|
|
139
150
|
const cycle = spec.cycle.trim();
|
|
140
151
|
assertTimerCycle(`startOn cycle`, cycle);
|
|
141
152
|
ctx.startTimer = { cycle };
|
|
142
153
|
}
|
|
143
|
-
else if (
|
|
154
|
+
else if ("after" in spec) {
|
|
144
155
|
const after = spec.after.trim();
|
|
145
156
|
assertTimerDuration(`startOn after`, after);
|
|
146
157
|
ctx.startTimer = { after };
|
|
@@ -156,10 +167,16 @@ function makeBuilder(id, out, ctx, isRoot = false) {
|
|
|
156
167
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
157
168
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
158
169
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
170
|
+
const caseNodes = [];
|
|
171
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
172
|
+
if (value === "default")
|
|
173
|
+
continue;
|
|
174
|
+
if (typeof fn !== "function")
|
|
175
|
+
throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
176
|
+
caseNodes.push({ value, body: child(fn, false) });
|
|
177
|
+
}
|
|
178
|
+
if (caseNodes.length === 0)
|
|
161
179
|
throw new Error(`switch("${subject}") needs at least one case`);
|
|
162
|
-
const caseNodes = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
|
|
163
180
|
const def = cases.default ? child(cases.default, false) : undefined;
|
|
164
181
|
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
165
182
|
return b;
|
|
@@ -274,14 +291,22 @@ export function defineFlow(id, second, third) {
|
|
|
274
291
|
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
275
292
|
}
|
|
276
293
|
const contracts = typeof second === "function" ? {} : second;
|
|
277
|
-
|
|
278
|
-
if (typeof build !== "function") {
|
|
294
|
+
if (typeof second !== "function" && typeof third !== "function") {
|
|
279
295
|
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
280
296
|
}
|
|
281
297
|
const steps = [];
|
|
282
298
|
const handlers = {};
|
|
283
299
|
const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
284
|
-
|
|
300
|
+
if (typeof second === "function") {
|
|
301
|
+
second(makeBuilder(id, steps, ctx, true));
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const build = third;
|
|
305
|
+
if (typeof build !== "function") {
|
|
306
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
307
|
+
}
|
|
308
|
+
build(makeBuilder(id, steps, ctx, true));
|
|
309
|
+
}
|
|
285
310
|
if (steps.length === 0)
|
|
286
311
|
throw new Error(`flow "${id}" declared no steps`);
|
|
287
312
|
const flow = { kind: "declarative", id, steps, handlers };
|
|
@@ -341,6 +366,16 @@ export function externalJobTypes(flow) {
|
|
|
341
366
|
});
|
|
342
367
|
return types;
|
|
343
368
|
}
|
|
369
|
+
function requireTimerAt(node) {
|
|
370
|
+
if (node.at === undefined)
|
|
371
|
+
throw new Error(`timer event is missing its { at } value`);
|
|
372
|
+
return node.at;
|
|
373
|
+
}
|
|
374
|
+
function requireStartAt(timer) {
|
|
375
|
+
if (timer.at === undefined)
|
|
376
|
+
throw new Error(`timer start is missing its { at } value`);
|
|
377
|
+
return timer.at;
|
|
378
|
+
}
|
|
344
379
|
class Compiler {
|
|
345
380
|
flow;
|
|
346
381
|
nodes = [];
|
|
@@ -429,7 +464,7 @@ class Compiler {
|
|
|
429
464
|
const id = node.name;
|
|
430
465
|
const body = node.after !== undefined
|
|
431
466
|
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
432
|
-
: ` <bpmn:timeDate>${escapeXml(node
|
|
467
|
+
: ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
|
|
433
468
|
this.nodes.push({
|
|
434
469
|
id,
|
|
435
470
|
scope: this.currentScope(),
|
|
@@ -660,7 +695,7 @@ class Compiler {
|
|
|
660
695
|
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
661
696
|
: t.after !== undefined
|
|
662
697
|
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
663
|
-
: `<bpmn:timeDate>${escapeXml(t
|
|
698
|
+
: `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
|
|
664
699
|
return (` <bpmn:startEvent id="Start">\n` +
|
|
665
700
|
` ${outgoingOnly(outg)}\n` +
|
|
666
701
|
` <bpmn:timerEventDefinition>\n` +
|
package/dist/envelope.js
CHANGED
|
@@ -29,6 +29,15 @@ const SCALARS = new Set([
|
|
|
29
29
|
"boolean",
|
|
30
30
|
"datetime",
|
|
31
31
|
]);
|
|
32
|
+
class EnvelopeDef {
|
|
33
|
+
name;
|
|
34
|
+
fields;
|
|
35
|
+
type;
|
|
36
|
+
constructor(name, fields) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.fields = fields;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
32
41
|
function normaliseField(name, spec) {
|
|
33
42
|
if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
|
|
34
43
|
throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
|
|
@@ -56,7 +65,5 @@ export function envelope(name, fields) {
|
|
|
56
65
|
throw new Error(`envelope "${name}" declares no fields`);
|
|
57
66
|
for (const f of list)
|
|
58
67
|
assertIdent("envelope field name", f.name);
|
|
59
|
-
|
|
60
|
-
// minimal while the declared type carries the inference.
|
|
61
|
-
return { name, fields: list, type: undefined };
|
|
68
|
+
return new EnvelopeDef(name, list);
|
|
62
69
|
}
|
package/dist/imperative.js
CHANGED
|
@@ -55,6 +55,17 @@ export function imperativeToBpmn(wf) {
|
|
|
55
55
|
`</bpmn:definitions>\n`);
|
|
56
56
|
}
|
|
57
57
|
const SUSPEND = Symbol("nanobpm.workflow.suspend");
|
|
58
|
+
class SuspendError extends Error {
|
|
59
|
+
[SUSPEND];
|
|
60
|
+
constructor(key, result) {
|
|
61
|
+
super("nanobpm.workflow suspend");
|
|
62
|
+
this[SUSPEND] = { key, result };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function assertReplayValue(_value, _name) {
|
|
66
|
+
// The journal value was recorded from the same ordinal/name run call on an
|
|
67
|
+
// earlier pass; TypeScript cannot express that cross-replay invariant.
|
|
68
|
+
}
|
|
58
69
|
/**
|
|
59
70
|
* Replay the orchestration function against a journal. Returns `{ done: true }`
|
|
60
71
|
* if the function ran to completion, or the frontier step (the one un-recorded
|
|
@@ -70,12 +81,13 @@ export async function replayOnce(wf, input, journal) {
|
|
|
70
81
|
async run(name, fn) {
|
|
71
82
|
const key = `${++ordinal}:${name}`;
|
|
72
83
|
if (Object.prototype.hasOwnProperty.call(journal, key)) {
|
|
73
|
-
|
|
84
|
+
const value = journal[key];
|
|
85
|
+
assertReplayValue(value, name);
|
|
86
|
+
return value; // replay: return recorded value, no side effect
|
|
74
87
|
}
|
|
75
|
-
const result = (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw carrier;
|
|
88
|
+
const result = (await fn()) ?? null; // frontier: the ONE real side effect this turn
|
|
89
|
+
assertReplayValue(result, name);
|
|
90
|
+
throw new SuspendError(key, result);
|
|
79
91
|
},
|
|
80
92
|
};
|
|
81
93
|
try {
|
|
@@ -83,9 +95,8 @@ export async function replayOnce(wf, input, journal) {
|
|
|
83
95
|
return { done: true };
|
|
84
96
|
}
|
|
85
97
|
catch (e) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return { done: false, frontier: s };
|
|
98
|
+
if (e instanceof SuspendError)
|
|
99
|
+
return { done: false, frontier: e[SUSPEND] };
|
|
89
100
|
throw e; // a genuine error in the orchestration or a handler
|
|
90
101
|
}
|
|
91
102
|
}
|
package/dist/layout.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// stays authoritative. Re-run layout whenever the flow changes rather than
|
|
17
17
|
// hand-editing the generated diagram.
|
|
18
18
|
import { declarativeToBpmn } from "./declarative.js";
|
|
19
|
+
function isRecord(v) {
|
|
20
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
19
22
|
/**
|
|
20
23
|
* Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
|
|
21
24
|
* `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
|
|
@@ -36,7 +39,7 @@ export async function layoutBpmn(bpmnXml) {
|
|
|
36
39
|
// genuinely cannot be resolved. A different failure (the package is present
|
|
37
40
|
// but fails to load — syntax, a broken transitive dep, a runtime throw at
|
|
38
41
|
// import time) must surface as-is so it isn't masked as a missing dependency.
|
|
39
|
-
if (cause
|
|
42
|
+
if (!isRecord(cause) || cause.code !== "ERR_MODULE_NOT_FOUND") {
|
|
40
43
|
throw cause;
|
|
41
44
|
}
|
|
42
45
|
throw new Error('layoutBpmn requires the optional peer dependency "bpmn-auto-layout". ' +
|
package/dist/types.d.ts
CHANGED
|
@@ -34,7 +34,9 @@ export interface StepContract {
|
|
|
34
34
|
* untyped `JsonObject`. */
|
|
35
35
|
export type FlowContracts = Record<string, StepContract>;
|
|
36
36
|
/** Handler for a declarative `run` step: does real work, returns variables. */
|
|
37
|
-
export type StepHandler =
|
|
37
|
+
export type StepHandler<V extends JsonObject = JsonObject, R extends JsonObject = JsonObject> = {
|
|
38
|
+
bivarianceHack(job: Job<V>): Promise<R | void> | R | void;
|
|
39
|
+
}["bivarianceHack"];
|
|
38
40
|
/** A node in a declarative flow tree. Leaf activities carry optional data
|
|
39
41
|
* envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
|
|
40
42
|
* combinators carry nested `FlowNode[]` bodies. */
|
package/dist/worker.js
CHANGED
|
@@ -17,6 +17,9 @@ import { WorkflowClient } from "./client.js";
|
|
|
17
17
|
import { replayOnce } from "./imperative.js";
|
|
18
18
|
import { walkNodes } from "./declarative.js";
|
|
19
19
|
import { jobType } from "./xml.js";
|
|
20
|
+
function isJsonObject(v) {
|
|
21
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
20
23
|
export class Worker {
|
|
21
24
|
client;
|
|
22
25
|
name;
|
|
@@ -47,13 +50,16 @@ export class Worker {
|
|
|
47
50
|
this.addRoute(wf.orchestrateType, {
|
|
48
51
|
workflowId: wf.id,
|
|
49
52
|
handle: async (job) => {
|
|
50
|
-
const input = job.variables.input
|
|
51
|
-
const journal = job.variables.journal
|
|
53
|
+
const input = isJsonObject(job.variables.input) ? job.variables.input : {};
|
|
54
|
+
const journal = isJsonObject(job.variables.journal) ? job.variables.journal : {};
|
|
52
55
|
const step = await replayOnce(wf, input, journal);
|
|
53
|
-
if (step.done)
|
|
54
|
-
|
|
56
|
+
if (step.done) {
|
|
57
|
+
const variables = { wfDone: true };
|
|
58
|
+
return { variables, step: "__done" };
|
|
59
|
+
}
|
|
55
60
|
const next = { ...journal, [step.frontier.key]: step.frontier.result };
|
|
56
|
-
|
|
61
|
+
const variables = { journal: next, wfDone: false };
|
|
62
|
+
return { variables, step: step.frontier.key };
|
|
57
63
|
},
|
|
58
64
|
});
|
|
59
65
|
}
|
|
@@ -76,7 +82,10 @@ export class Worker {
|
|
|
76
82
|
}
|
|
77
83
|
this.addRoute(jobType(wf.id, s.name), {
|
|
78
84
|
workflowId: wf.id,
|
|
79
|
-
handle: async (job) =>
|
|
85
|
+
handle: async (job) => {
|
|
86
|
+
const variables = (await handler(job)) ?? {};
|
|
87
|
+
return { variables };
|
|
88
|
+
},
|
|
80
89
|
});
|
|
81
90
|
});
|
|
82
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/client.ts
CHANGED
|
@@ -4,13 +4,60 @@
|
|
|
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
6
|
|
|
7
|
-
import { createCamundaClient } from "@nanobpm/nano-sdk";
|
|
7
|
+
import { createCamundaClient, JobActionReceiptSymbol, ProcessDefinitionId, ProcessInstanceKey } from "@nanobpm/nano-sdk";
|
|
8
8
|
import { declarativeToBpmn, walkNodes } from "./declarative.js";
|
|
9
9
|
import { imperativeToBpmn } from "./imperative.js";
|
|
10
10
|
import { layoutBpmn } from "./layout.js";
|
|
11
|
-
import type { DeclarativeFlow, DeployResult, Job, JsonObject, StartResult, Workflow } from "./types.js";
|
|
11
|
+
import type { DeclarativeFlow, DeployResult, Job, Json, JsonObject, StartResult, Workflow } from "./types.js";
|
|
12
12
|
import { assertWorkflowIds, messageName } from "./xml.js";
|
|
13
13
|
|
|
14
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
15
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function errorCause(e: unknown): unknown {
|
|
19
|
+
return isRecord(e) ? e.cause : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorCode(e: unknown): unknown {
|
|
23
|
+
return isRecord(e) ? e.code : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function errorMessage(e: unknown): string {
|
|
27
|
+
return isRecord(e) && typeof e.message === "string" ? e.message : String(e);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function errorStatus(e: unknown): number | undefined {
|
|
31
|
+
if (!isRecord(e)) return undefined;
|
|
32
|
+
if (typeof e.status === "number") return e.status;
|
|
33
|
+
const response = e.response;
|
|
34
|
+
return isRecord(response) && typeof response.status === "number" ? response.status : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isJson(v: unknown): v is Json {
|
|
38
|
+
if (v === null) return true;
|
|
39
|
+
switch (typeof v) {
|
|
40
|
+
case "boolean":
|
|
41
|
+
case "number":
|
|
42
|
+
case "string":
|
|
43
|
+
return true;
|
|
44
|
+
case "object":
|
|
45
|
+
if (Array.isArray(v)) return v.every(isJson);
|
|
46
|
+
return Object.values(v).every(isJson);
|
|
47
|
+
default:
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toJsonObject(v: unknown): JsonObject {
|
|
53
|
+
if (!isRecord(v)) throw new Error("SDK response was not an object");
|
|
54
|
+
const result: JsonObject = {};
|
|
55
|
+
for (const [key, value] of Object.entries(v)) {
|
|
56
|
+
if (isJson(value)) result[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
14
61
|
/** Render a workflow (either surface) to its executable BPMN model. */
|
|
15
62
|
export function toBpmn(wf: Workflow): string {
|
|
16
63
|
assertWorkflowIds(wf);
|
|
@@ -43,8 +90,8 @@ export async function toDeployableBpmn(
|
|
|
43
90
|
// Only fall back for the "optional dep not installed" case (layoutBpmn wraps
|
|
44
91
|
// it with an ERR_MODULE_NOT_FOUND cause). Any other failure is a real layout
|
|
45
92
|
// problem and must surface rather than silently deploy an uninspectable model.
|
|
46
|
-
const cause = (e
|
|
47
|
-
if (cause
|
|
93
|
+
const cause = errorCause(e);
|
|
94
|
+
if (errorCode(cause) !== "ERR_MODULE_NOT_FOUND") throw e;
|
|
48
95
|
if (!warnedNoLayout) {
|
|
49
96
|
warnedNoLayout = true;
|
|
50
97
|
console.warn(
|
|
@@ -69,20 +116,20 @@ export interface NanoSdkClient {
|
|
|
69
116
|
createDeployment(
|
|
70
117
|
input: { resources: File[]; [k: string]: unknown },
|
|
71
118
|
options?: unknown,
|
|
72
|
-
): Promise<
|
|
119
|
+
): Promise<unknown>;
|
|
73
120
|
createProcessInstance(
|
|
74
121
|
input: { processDefinitionId: string; variables?: JsonObject },
|
|
75
122
|
options?: unknown,
|
|
76
|
-
): Promise<
|
|
123
|
+
): Promise<unknown>;
|
|
77
124
|
correlateMessage(
|
|
78
125
|
input: { name: string; correlationKey: string; variables?: JsonObject },
|
|
79
126
|
options?: unknown,
|
|
80
|
-
): Promise<
|
|
127
|
+
): Promise<unknown>;
|
|
81
128
|
getProcessInstance(
|
|
82
129
|
input: { processInstanceKey: string },
|
|
83
130
|
consistency: { consistency: { waitUpToMs: number } },
|
|
84
131
|
options?: unknown,
|
|
85
|
-
): Promise<
|
|
132
|
+
): Promise<unknown>;
|
|
86
133
|
createJobWorker(cfg: JobWorkerConfig): NanoJobWorker;
|
|
87
134
|
}
|
|
88
135
|
|
|
@@ -156,9 +203,8 @@ export class WorkflowError extends Error {
|
|
|
156
203
|
* HTTP status when the SDK attached one. */
|
|
157
204
|
function asWorkflowError(e: unknown, what: string): WorkflowError {
|
|
158
205
|
if (e instanceof WorkflowError) return e;
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
return new WorkflowError(`${what} failed: ${err?.message ?? String(e)}`, status);
|
|
206
|
+
const status = errorStatus(e);
|
|
207
|
+
return new WorkflowError(`${what} failed: ${errorMessage(e)}`, status);
|
|
162
208
|
}
|
|
163
209
|
|
|
164
210
|
export class WorkflowClient {
|
|
@@ -175,13 +221,49 @@ export class WorkflowClient {
|
|
|
175
221
|
return;
|
|
176
222
|
}
|
|
177
223
|
if (!opts?.baseUrl) throw new Error("WorkflowClient needs a baseUrl or a client");
|
|
178
|
-
|
|
224
|
+
const raw = createCamundaClient({
|
|
179
225
|
config: {
|
|
180
226
|
CAMUNDA_REST_ADDRESS: opts.baseUrl.replace(/\/+$/, ""),
|
|
181
227
|
...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
|
|
182
228
|
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
183
229
|
},
|
|
184
|
-
})
|
|
230
|
+
});
|
|
231
|
+
this.sdk = {
|
|
232
|
+
createDeployment: (input) => raw.createDeployment(input),
|
|
233
|
+
createProcessInstance: (input) =>
|
|
234
|
+
raw.createProcessInstance(
|
|
235
|
+
{
|
|
236
|
+
...input,
|
|
237
|
+
processDefinitionId: ProcessDefinitionId.assumeExists(input.processDefinitionId),
|
|
238
|
+
},
|
|
239
|
+
),
|
|
240
|
+
correlateMessage: (input) => raw.correlateMessage(input),
|
|
241
|
+
getProcessInstance: (input, consistency) =>
|
|
242
|
+
raw.getProcessInstance(
|
|
243
|
+
{
|
|
244
|
+
processInstanceKey: ProcessInstanceKey.assumeExists(input.processInstanceKey),
|
|
245
|
+
},
|
|
246
|
+
consistency,
|
|
247
|
+
),
|
|
248
|
+
createJobWorker: (cfg) => {
|
|
249
|
+
const worker = raw.createJobWorker({
|
|
250
|
+
...cfg,
|
|
251
|
+
jobHandler: async (job) => {
|
|
252
|
+
await cfg.jobHandler(job);
|
|
253
|
+
return JobActionReceiptSymbol;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
return {
|
|
257
|
+
start: () => {
|
|
258
|
+
void worker.start();
|
|
259
|
+
},
|
|
260
|
+
stop: () => worker.stop(),
|
|
261
|
+
stopGracefully: async (opts) => {
|
|
262
|
+
await worker.stopGracefully(opts);
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
185
267
|
}
|
|
186
268
|
|
|
187
269
|
/**
|
|
@@ -195,7 +277,7 @@ export class WorkflowClient {
|
|
|
195
277
|
const xml = await toDeployableBpmn(wf, opts);
|
|
196
278
|
const file = new File([xml], `${wf.id}.bpmn`, { type: "text/xml" });
|
|
197
279
|
try {
|
|
198
|
-
return (await this.sdk.createDeployment({ resources: [file] }))
|
|
280
|
+
return toJsonObject(await this.sdk.createDeployment({ resources: [file] }));
|
|
199
281
|
} catch (e) {
|
|
200
282
|
throw asWorkflowError(e, "deploy");
|
|
201
283
|
}
|
|
@@ -210,10 +292,10 @@ export class WorkflowClient {
|
|
|
210
292
|
const variables: JsonObject =
|
|
211
293
|
wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
|
|
212
294
|
try {
|
|
213
|
-
return (await this.sdk.createProcessInstance({
|
|
295
|
+
return toJsonObject(await this.sdk.createProcessInstance({
|
|
214
296
|
processDefinitionId: wf.id,
|
|
215
297
|
variables,
|
|
216
|
-
}))
|
|
298
|
+
}));
|
|
217
299
|
} catch (e) {
|
|
218
300
|
throw asWorkflowError(e, "start");
|
|
219
301
|
}
|
|
@@ -242,11 +324,11 @@ export class WorkflowClient {
|
|
|
242
324
|
);
|
|
243
325
|
}
|
|
244
326
|
try {
|
|
245
|
-
return (await this.sdk.correlateMessage({
|
|
327
|
+
return toJsonObject(await this.sdk.correlateMessage({
|
|
246
328
|
name: messageName(flow.id, signalName),
|
|
247
329
|
correlationKey,
|
|
248
330
|
variables,
|
|
249
|
-
}))
|
|
331
|
+
}));
|
|
250
332
|
} catch (e) {
|
|
251
333
|
throw asWorkflowError(e, `signal "${signalName}"`);
|
|
252
334
|
}
|
|
@@ -256,10 +338,10 @@ export class WorkflowClient {
|
|
|
256
338
|
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
257
339
|
async getInstance(processInstanceKey: string): Promise<JsonObject | null> {
|
|
258
340
|
try {
|
|
259
|
-
return (await this.sdk.getProcessInstance(
|
|
341
|
+
return toJsonObject(await this.sdk.getProcessInstance(
|
|
260
342
|
{ processInstanceKey },
|
|
261
343
|
{ consistency: { waitUpToMs: 0 } },
|
|
262
|
-
))
|
|
344
|
+
));
|
|
263
345
|
} catch {
|
|
264
346
|
return null;
|
|
265
347
|
}
|
package/src/declarative.ts
CHANGED
|
@@ -54,9 +54,9 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
|
|
|
54
54
|
// --- Authoring surface -------------------------------------------------------
|
|
55
55
|
|
|
56
56
|
/** The TS payload type of a contract's input envelope (untyped fallback). */
|
|
57
|
-
type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] : JsonObject;
|
|
57
|
+
type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] & JsonObject : JsonObject;
|
|
58
58
|
/** The TS payload type of a contract's output envelope (untyped fallback). */
|
|
59
|
-
type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] : JsonObject;
|
|
59
|
+
type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] & JsonObject : JsonObject;
|
|
60
60
|
/** The input payload type of step `K` under contracts `C`. */
|
|
61
61
|
type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
|
|
62
62
|
/** The output payload type of step `K` under contracts `C`. */
|
|
@@ -64,7 +64,7 @@ type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : Json
|
|
|
64
64
|
|
|
65
65
|
/** A typed handler for a `run` step: its job variables and result are resolved
|
|
66
66
|
* from the flow contracts by the step name. */
|
|
67
|
-
type TypedHandler<V, R> = (job: {
|
|
67
|
+
type TypedHandler<V extends JsonObject, R extends JsonObject> = (job: {
|
|
68
68
|
jobKey: string;
|
|
69
69
|
processInstanceKey: string;
|
|
70
70
|
elementId: string;
|
|
@@ -209,6 +209,21 @@ interface BuilderCtx {
|
|
|
209
209
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
210
210
|
* invalid model, so reject them at authoring time. */
|
|
211
211
|
const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
|
|
212
|
+
const TIMER_START_KEYS = ["cycle", "after", "at"] as const;
|
|
213
|
+
|
|
214
|
+
function timerStartValue(
|
|
215
|
+
spec: { cycle: string } | { after: string } | { at: string },
|
|
216
|
+
key: (typeof TIMER_START_KEYS)[number],
|
|
217
|
+
): string | undefined {
|
|
218
|
+
switch (key) {
|
|
219
|
+
case "cycle":
|
|
220
|
+
return "cycle" in spec ? spec.cycle : undefined;
|
|
221
|
+
case "after":
|
|
222
|
+
return "after" in spec ? spec.after : undefined;
|
|
223
|
+
case "at":
|
|
224
|
+
return "at" in spec ? spec.at : undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
212
227
|
|
|
213
228
|
function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
214
229
|
assertIdent("step name", name);
|
|
@@ -255,81 +270,88 @@ function makeBuilder<C extends object>(
|
|
|
255
270
|
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
|
|
256
271
|
return body;
|
|
257
272
|
};
|
|
258
|
-
const b = {
|
|
259
|
-
run
|
|
273
|
+
const b: FlowBuilder<C> = {
|
|
274
|
+
run<K extends string>(
|
|
275
|
+
name: K,
|
|
276
|
+
handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>,
|
|
277
|
+
): FlowBuilder<C> {
|
|
260
278
|
claimName(ctx, id, name);
|
|
261
279
|
if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
|
|
262
280
|
ctx.handlers[name] = handler;
|
|
263
281
|
out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
|
|
264
|
-
return b
|
|
282
|
+
return b;
|
|
265
283
|
},
|
|
266
284
|
task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
|
|
267
285
|
claimName(ctx, id, name);
|
|
268
286
|
const override = opts?.jobType;
|
|
269
287
|
if (override !== undefined) assertJobType("task jobType", override);
|
|
270
288
|
out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
|
|
271
|
-
return b
|
|
289
|
+
return b;
|
|
272
290
|
},
|
|
273
291
|
signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
|
|
274
292
|
claimName(ctx, id, name);
|
|
275
293
|
if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
276
294
|
assertIdent("correlationKey", opts.correlationKey);
|
|
277
295
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
278
|
-
return b
|
|
296
|
+
return b;
|
|
279
297
|
},
|
|
280
|
-
timer(name: string, opts: { after
|
|
298
|
+
timer(name: string, opts: { after: string } | { at: string }): FlowBuilder<C> {
|
|
281
299
|
claimName(ctx, id, name);
|
|
282
|
-
const hasAfter = typeof opts
|
|
283
|
-
const hasAt = typeof opts
|
|
300
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
301
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
284
302
|
if (hasAfter === hasAt) {
|
|
285
303
|
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
286
304
|
}
|
|
287
|
-
if (
|
|
288
|
-
const after =
|
|
305
|
+
if ("after" in opts) {
|
|
306
|
+
const after = opts.after.trim();
|
|
289
307
|
assertTimerDuration(`timer("${name}") after`, after);
|
|
290
308
|
out.push({ kind: "timer", name, after });
|
|
291
309
|
} else {
|
|
292
|
-
const at =
|
|
310
|
+
const at = opts.at.trim();
|
|
293
311
|
assertTimerDate(`timer("${name}") at`, at);
|
|
294
312
|
out.push({ kind: "timer", name, at });
|
|
295
313
|
}
|
|
296
|
-
return b
|
|
314
|
+
return b;
|
|
297
315
|
},
|
|
298
|
-
startOn(spec: { cycle
|
|
316
|
+
startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C> {
|
|
299
317
|
if (!isRoot) {
|
|
300
318
|
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
301
319
|
}
|
|
302
320
|
if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
303
321
|
if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
322
|
+
const setCount = TIMER_START_KEYS.filter((key) => typeof timerStartValue(spec, key) === "string").length;
|
|
323
|
+
if (setCount !== 1) {
|
|
306
324
|
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
307
325
|
}
|
|
308
|
-
if (
|
|
326
|
+
if ("cycle" in spec) {
|
|
309
327
|
const cycle = spec.cycle.trim();
|
|
310
328
|
assertTimerCycle(`startOn cycle`, cycle);
|
|
311
329
|
ctx.startTimer = { cycle };
|
|
312
|
-
} else if (
|
|
330
|
+
} else if ("after" in spec) {
|
|
313
331
|
const after = spec.after.trim();
|
|
314
332
|
assertTimerDuration(`startOn after`, after);
|
|
315
333
|
ctx.startTimer = { after };
|
|
316
334
|
} else {
|
|
317
|
-
const at =
|
|
335
|
+
const at = spec.at.trim();
|
|
318
336
|
assertTimerDate(`startOn at`, at);
|
|
319
337
|
ctx.startTimer = { at };
|
|
320
338
|
}
|
|
321
|
-
return b
|
|
339
|
+
return b;
|
|
322
340
|
},
|
|
323
341
|
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
324
342
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
325
343
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
326
344
|
}
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
345
|
+
const caseNodes: SwitchCase[] = [];
|
|
346
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
347
|
+
if (value === "default") continue;
|
|
348
|
+
if (typeof fn !== "function") throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
349
|
+
caseNodes.push({ value, body: child(fn, false) });
|
|
350
|
+
}
|
|
351
|
+
if (caseNodes.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
|
|
330
352
|
const def = cases.default ? child(cases.default, false) : undefined;
|
|
331
353
|
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
332
|
-
return b
|
|
354
|
+
return b;
|
|
333
355
|
},
|
|
334
356
|
branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C> {
|
|
335
357
|
if (typeof condition !== "string" || condition.trim() === "") {
|
|
@@ -342,12 +364,12 @@ function makeBuilder<C extends object>(
|
|
|
342
364
|
then: child(arms.then, false),
|
|
343
365
|
else: arms.else ? child(arms.else, false) : undefined,
|
|
344
366
|
});
|
|
345
|
-
return b
|
|
367
|
+
return b;
|
|
346
368
|
},
|
|
347
369
|
loop(body: Block<C>): FlowBuilder<C> {
|
|
348
370
|
if (typeof body !== "function") throw new Error(`loop() needs a body function`);
|
|
349
371
|
out.push({ kind: "loop", body: child(body, true) });
|
|
350
|
-
return b
|
|
372
|
+
return b;
|
|
351
373
|
},
|
|
352
374
|
parallel(branches: Block<C>[]): FlowBuilder<C> {
|
|
353
375
|
if (!Array.isArray(branches) || branches.length < 2) {
|
|
@@ -361,7 +383,7 @@ function makeBuilder<C extends object>(
|
|
|
361
383
|
// loop. Scope branches (loopDepth resets to 0) so cross-boundary
|
|
362
384
|
// break/continue is rejected at build time, matching forEach.
|
|
363
385
|
out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
|
|
364
|
-
return b
|
|
386
|
+
return b;
|
|
365
387
|
},
|
|
366
388
|
forEach(
|
|
367
389
|
collection: string,
|
|
@@ -422,20 +444,20 @@ function makeBuilder<C extends object>(
|
|
|
422
444
|
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
423
445
|
}
|
|
424
446
|
out.push(node);
|
|
425
|
-
return b
|
|
447
|
+
return b;
|
|
426
448
|
},
|
|
427
449
|
break(): FlowBuilder<C> {
|
|
428
450
|
if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
429
451
|
out.push({ kind: "break" });
|
|
430
|
-
return b
|
|
452
|
+
return b;
|
|
431
453
|
},
|
|
432
454
|
continue(): FlowBuilder<C> {
|
|
433
455
|
if (ctx.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
|
|
434
456
|
out.push({ kind: "continue" });
|
|
435
|
-
return b
|
|
457
|
+
return b;
|
|
436
458
|
},
|
|
437
459
|
};
|
|
438
|
-
return b
|
|
460
|
+
return b;
|
|
439
461
|
}
|
|
440
462
|
|
|
441
463
|
/**
|
|
@@ -459,14 +481,21 @@ export function defineFlow(
|
|
|
459
481
|
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
460
482
|
}
|
|
461
483
|
const contracts: FlowContracts = typeof second === "function" ? {} : second;
|
|
462
|
-
|
|
463
|
-
if (typeof build !== "function") {
|
|
484
|
+
if (typeof second !== "function" && typeof third !== "function") {
|
|
464
485
|
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
465
486
|
}
|
|
466
487
|
const steps: FlowNode[] = [];
|
|
467
488
|
const handlers: Record<string, StepHandler> = {};
|
|
468
489
|
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
469
|
-
|
|
490
|
+
if (typeof second === "function") {
|
|
491
|
+
second(makeBuilder(id, steps, ctx, true));
|
|
492
|
+
} else {
|
|
493
|
+
const build = third;
|
|
494
|
+
if (typeof build !== "function") {
|
|
495
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
496
|
+
}
|
|
497
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx, true));
|
|
498
|
+
}
|
|
470
499
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
471
500
|
const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
|
|
472
501
|
if (ctx.startTimer) flow.startTimer = ctx.startTimer;
|
|
@@ -522,6 +551,16 @@ export function externalJobTypes(flow: DeclarativeFlow): string[] {
|
|
|
522
551
|
return types;
|
|
523
552
|
}
|
|
524
553
|
|
|
554
|
+
function requireTimerAt(node: { at?: string }): string {
|
|
555
|
+
if (node.at === undefined) throw new Error(`timer event is missing its { at } value`);
|
|
556
|
+
return node.at;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function requireStartAt(timer: TimerStart): string {
|
|
560
|
+
if (timer.at === undefined) throw new Error(`timer start is missing its { at } value`);
|
|
561
|
+
return timer.at;
|
|
562
|
+
}
|
|
563
|
+
|
|
525
564
|
// --- Model emitter (two-phase graph compiler) --------------------------------
|
|
526
565
|
|
|
527
566
|
interface RenderNode {
|
|
@@ -645,7 +684,7 @@ class Compiler {
|
|
|
645
684
|
const body =
|
|
646
685
|
node.after !== undefined
|
|
647
686
|
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
648
|
-
: ` <bpmn:timeDate>${escapeXml(node
|
|
687
|
+
: ` <bpmn:timeDate>${escapeXml(requireTimerAt(node))}</bpmn:timeDate>\n`;
|
|
649
688
|
this.nodes.push({
|
|
650
689
|
id,
|
|
651
690
|
scope: this.currentScope(),
|
|
@@ -888,7 +927,7 @@ class Compiler {
|
|
|
888
927
|
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
889
928
|
: t.after !== undefined
|
|
890
929
|
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
891
|
-
: `<bpmn:timeDate>${escapeXml(t
|
|
930
|
+
: `<bpmn:timeDate>${escapeXml(requireStartAt(t))}</bpmn:timeDate>`;
|
|
892
931
|
return (
|
|
893
932
|
` <bpmn:startEvent id="Start">\n` +
|
|
894
933
|
` ${outgoingOnly(outg)}\n` +
|
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
|
@@ -45,7 +45,12 @@ export interface StepContract {
|
|
|
45
45
|
export type FlowContracts = Record<string, StepContract>;
|
|
46
46
|
|
|
47
47
|
/** Handler for a declarative `run` step: does real work, returns variables. */
|
|
48
|
-
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"];
|
|
49
54
|
|
|
50
55
|
/** A node in a declarative flow tree. Leaf activities carry optional data
|
|
51
56
|
* envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
|
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,12 +105,16 @@ 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 {
|
|
@@ -129,7 +137,10 @@ export class Worker {
|
|
|
129
137
|
}
|
|
130
138
|
this.addRoute(jobType(wf.id, s.name), {
|
|
131
139
|
workflowId: wf.id,
|
|
132
|
-
handle: async (job) =>
|
|
140
|
+
handle: async (job) => {
|
|
141
|
+
const variables: JsonObject = (await handler(job)) ?? {};
|
|
142
|
+
return { variables };
|
|
143
|
+
},
|
|
133
144
|
});
|
|
134
145
|
});
|
|
135
146
|
}
|