@nanobpm/workflow 0.6.0 → 0.7.0
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/declarative.d.ts +30 -0
- package/dist/declarative.js +94 -5
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +53 -0
- package/package.json +2 -2
- package/src/declarative.ts +122 -5
- package/src/index.ts +1 -0
- package/src/types.ts +25 -0
- package/src/xml.ts +65 -0
package/dist/declarative.d.ts
CHANGED
|
@@ -55,6 +55,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
55
55
|
signal<K extends string>(name: K, opts: {
|
|
56
56
|
correlationKey: string;
|
|
57
57
|
}): FlowBuilder<C>;
|
|
58
|
+
/**
|
|
59
|
+
* A durable wait for a point in time (a BPMN timer intermediate catch event).
|
|
60
|
+
* Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
|
|
61
|
+
* FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
|
|
62
|
+
* absolute ISO-8601 instant (or FEEL expression). The engine holds the token
|
|
63
|
+
* durably until the timer fires, then continues. Use it for in-flow delays and
|
|
64
|
+
* scheduled continuations (e.g. "wait 24h, then re-poll").
|
|
65
|
+
*/
|
|
66
|
+
timer<K extends string>(name: K, opts: {
|
|
67
|
+
after: string;
|
|
68
|
+
} | {
|
|
69
|
+
at: string;
|
|
70
|
+
}): FlowBuilder<C>;
|
|
71
|
+
/**
|
|
72
|
+
* Make this flow's start event a durable TIMER start rather than an explicit
|
|
73
|
+
* `client.start(...)`. Must be the first statement, at the top level. Pass
|
|
74
|
+
* exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
|
|
75
|
+
* `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
|
|
76
|
+
* from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
|
|
77
|
+
* is the model-native, durable, single-fire-per-cluster replacement for an
|
|
78
|
+
* app-side cron: the schedule lives in the deployable model, and the engine
|
|
79
|
+
* (not each app replica) owns firing it exactly once.
|
|
80
|
+
*/
|
|
81
|
+
startOn(spec: {
|
|
82
|
+
cycle: string;
|
|
83
|
+
} | {
|
|
84
|
+
after: string;
|
|
85
|
+
} | {
|
|
86
|
+
at: string;
|
|
87
|
+
}): FlowBuilder<C>;
|
|
58
88
|
/**
|
|
59
89
|
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
60
90
|
* expression (usually a variable name); each case routes when `subject` equals
|
package/dist/declarative.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
// Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
|
|
35
35
|
// zeebe:property), so the generated .bpmn is ejectable to model-first with its
|
|
36
36
|
// typed contracts intact.
|
|
37
|
-
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
37
|
+
import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
|
|
38
38
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
39
39
|
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
40
40
|
* invalid model, so reject them at authoring time. */
|
|
@@ -61,7 +61,7 @@ function contractEnvelopes(ctx, name) {
|
|
|
61
61
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
62
62
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
63
63
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
64
|
-
function makeBuilder(id, out, ctx) {
|
|
64
|
+
function makeBuilder(id, out, ctx, isRoot = false) {
|
|
65
65
|
const child = (fn, inLoop) => {
|
|
66
66
|
const body = [];
|
|
67
67
|
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
@@ -93,6 +93,54 @@ function makeBuilder(id, out, ctx) {
|
|
|
93
93
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
94
94
|
return b;
|
|
95
95
|
},
|
|
96
|
+
timer(name, opts) {
|
|
97
|
+
claimName(ctx, id, name);
|
|
98
|
+
const hasAfter = typeof opts?.after === "string";
|
|
99
|
+
const hasAt = typeof opts?.at === "string";
|
|
100
|
+
if (hasAfter === hasAt) {
|
|
101
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
102
|
+
}
|
|
103
|
+
if (hasAfter) {
|
|
104
|
+
const after = opts.after.trim();
|
|
105
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
106
|
+
out.push({ kind: "timer", name, after });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const at = opts.at.trim();
|
|
110
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
111
|
+
out.push({ kind: "timer", name, at });
|
|
112
|
+
}
|
|
113
|
+
return b;
|
|
114
|
+
},
|
|
115
|
+
startOn(spec) {
|
|
116
|
+
if (!isRoot) {
|
|
117
|
+
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
118
|
+
}
|
|
119
|
+
if (out.length !== 0)
|
|
120
|
+
throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
121
|
+
if (ctx.startTimer)
|
|
122
|
+
throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
123
|
+
const set = ["cycle", "after", "at"].filter((k) => typeof spec?.[k] === "string");
|
|
124
|
+
if (set.length !== 1) {
|
|
125
|
+
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
126
|
+
}
|
|
127
|
+
if (spec.cycle !== undefined) {
|
|
128
|
+
const cycle = spec.cycle.trim();
|
|
129
|
+
assertTimerCycle(`startOn cycle`, cycle);
|
|
130
|
+
ctx.startTimer = { cycle };
|
|
131
|
+
}
|
|
132
|
+
else if (spec.after !== undefined) {
|
|
133
|
+
const after = spec.after.trim();
|
|
134
|
+
assertTimerDuration(`startOn after`, after);
|
|
135
|
+
ctx.startTimer = { after };
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const at = spec.at.trim();
|
|
139
|
+
assertTimerDate(`startOn at`, at);
|
|
140
|
+
ctx.startTimer = { at };
|
|
141
|
+
}
|
|
142
|
+
return b;
|
|
143
|
+
},
|
|
96
144
|
switch(subject, cases) {
|
|
97
145
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
98
146
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
@@ -153,10 +201,13 @@ export function defineFlow(id, second, third) {
|
|
|
153
201
|
const steps = [];
|
|
154
202
|
const handlers = {};
|
|
155
203
|
const ctx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
156
|
-
build(makeBuilder(id, steps, ctx));
|
|
204
|
+
build(makeBuilder(id, steps, ctx, true));
|
|
157
205
|
if (steps.length === 0)
|
|
158
206
|
throw new Error(`flow "${id}" declared no steps`);
|
|
159
|
-
|
|
207
|
+
const flow = { kind: "declarative", id, steps, handlers };
|
|
208
|
+
if (ctx.startTimer)
|
|
209
|
+
flow.startTimer = ctx.startTimer;
|
|
210
|
+
return flow;
|
|
160
211
|
}
|
|
161
212
|
// --- Tree walkers ------------------------------------------------------------
|
|
162
213
|
/** Depth-first visit of every node in a flow tree (structural combinators
|
|
@@ -269,6 +320,21 @@ class Compiler {
|
|
|
269
320
|
` </bpmn:intermediateCatchEvent>`,
|
|
270
321
|
});
|
|
271
322
|
}
|
|
323
|
+
addTimerCatchEvent(node) {
|
|
324
|
+
const id = node.name;
|
|
325
|
+
const body = node.after !== undefined
|
|
326
|
+
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
327
|
+
: ` <bpmn:timeDate>${escapeXml(node.at)}</bpmn:timeDate>\n`;
|
|
328
|
+
this.nodes.push({
|
|
329
|
+
id,
|
|
330
|
+
render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
331
|
+
incomingOutgoing(inc, outg) +
|
|
332
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
333
|
+
body +
|
|
334
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
335
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
272
338
|
addGateway(id, name) {
|
|
273
339
|
const gwNode = {
|
|
274
340
|
id,
|
|
@@ -303,6 +369,11 @@ class Compiler {
|
|
|
303
369
|
this.recordEnvelope(node.payload);
|
|
304
370
|
return [this.newEdge(node.name)];
|
|
305
371
|
}
|
|
372
|
+
case "timer": {
|
|
373
|
+
this.addTimerCatchEvent(node);
|
|
374
|
+
this.connect(incoming, node.name);
|
|
375
|
+
return [this.newEdge(node.name)];
|
|
376
|
+
}
|
|
306
377
|
case "switch": {
|
|
307
378
|
const id = `Gw_${this.gw++}`;
|
|
308
379
|
const gw = this.addGateway(id, node.subject);
|
|
@@ -357,11 +428,29 @@ class Compiler {
|
|
|
357
428
|
}
|
|
358
429
|
}
|
|
359
430
|
}
|
|
431
|
+
/** Render the Start event — a plain none start, or a timer start when the
|
|
432
|
+
* flow declared `startOn(...)`. */
|
|
433
|
+
renderStart(outg) {
|
|
434
|
+
const t = this.flow.startTimer;
|
|
435
|
+
if (!t)
|
|
436
|
+
return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
|
|
437
|
+
const body = t.cycle !== undefined
|
|
438
|
+
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
439
|
+
: t.after !== undefined
|
|
440
|
+
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
441
|
+
: `<bpmn:timeDate>${escapeXml(t.at)}</bpmn:timeDate>`;
|
|
442
|
+
return (` <bpmn:startEvent id="Start">\n` +
|
|
443
|
+
` ${outgoingOnly(outg)}\n` +
|
|
444
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
445
|
+
` ${body}\n` +
|
|
446
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
447
|
+
` </bpmn:startEvent>`);
|
|
448
|
+
}
|
|
360
449
|
compile() {
|
|
361
450
|
// Start → top-level sequence → End.
|
|
362
451
|
this.nodes.push({
|
|
363
452
|
id: "Start",
|
|
364
|
-
render: (_inc, outg) =>
|
|
453
|
+
render: (_inc, outg) => this.renderStart(outg),
|
|
365
454
|
});
|
|
366
455
|
const s0 = this.newEdge("Start");
|
|
367
456
|
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,4 @@ export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./clien
|
|
|
9
9
|
export type { WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, } from "./client.js";
|
|
10
10
|
export { Worker } from "./worker.js";
|
|
11
11
|
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
12
|
-
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, FlowNode, SwitchCase, NodeEnvelopes, StepContract, FlowContracts, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|
|
12
|
+
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, FlowNode, SwitchCase, TimerStart, NodeEnvelopes, StepContract, FlowContracts, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type FlowNode = {
|
|
|
52
52
|
name: string;
|
|
53
53
|
correlationKey: string;
|
|
54
54
|
payload?: Envelope;
|
|
55
|
-
} | {
|
|
55
|
+
} | ({
|
|
56
|
+
kind: "timer";
|
|
57
|
+
name: string;
|
|
58
|
+
} & TimerAt) | {
|
|
56
59
|
kind: "switch";
|
|
57
60
|
subject: string;
|
|
58
61
|
cases: SwitchCase[];
|
|
@@ -70,6 +73,38 @@ export type FlowNode = {
|
|
|
70
73
|
} | {
|
|
71
74
|
kind: "continue";
|
|
72
75
|
};
|
|
76
|
+
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
77
|
+
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
|
78
|
+
* Encoded as an XOR so downstream consumers can rely on exactly-one. */
|
|
79
|
+
export type TimerAt = {
|
|
80
|
+
after: string;
|
|
81
|
+
at?: never;
|
|
82
|
+
} | {
|
|
83
|
+
at: string;
|
|
84
|
+
after?: never;
|
|
85
|
+
};
|
|
86
|
+
/** A flow's start-timer: the plain none start event becomes a durable timer
|
|
87
|
+
* start the engine fires on schedule. Exactly one of:
|
|
88
|
+
* - `cycle` — a recurring ISO-8601 interval (`R/PT1H`, `R5/PT30M`) or bare
|
|
89
|
+
* duration; the engine re-fires on each period. This is the model-native,
|
|
90
|
+
* durable, single-fire-per-cluster replacement for an app-side cron.
|
|
91
|
+
* - `after` — a one-shot ISO-8601 delay (`PT10S`) measured from deployment.
|
|
92
|
+
* - `at` — a one-shot absolute instant (an ISO-8601 date-time, or a FEEL
|
|
93
|
+
* `=` expression).
|
|
94
|
+
* Encoded as an XOR so the exactly-one invariant holds at the type level. */
|
|
95
|
+
export type TimerStart = {
|
|
96
|
+
cycle: string;
|
|
97
|
+
after?: never;
|
|
98
|
+
at?: never;
|
|
99
|
+
} | {
|
|
100
|
+
after: string;
|
|
101
|
+
cycle?: never;
|
|
102
|
+
at?: never;
|
|
103
|
+
} | {
|
|
104
|
+
at: string;
|
|
105
|
+
cycle?: never;
|
|
106
|
+
after?: never;
|
|
107
|
+
};
|
|
73
108
|
/** One case of a `switch`: routed when `subject = value` (FEEL equality). */
|
|
74
109
|
export interface SwitchCase {
|
|
75
110
|
value: string;
|
|
@@ -84,6 +119,9 @@ export interface DeclarativeFlow {
|
|
|
84
119
|
/** The flow's node tree (top-level sequence). */
|
|
85
120
|
steps: FlowNode[];
|
|
86
121
|
handlers: Record<string, StepHandler>;
|
|
122
|
+
/** When set, the flow's start event is a durable timer start (see
|
|
123
|
+
* {@link TimerStart}) rather than a plain none start. */
|
|
124
|
+
startTimer?: TimerStart;
|
|
87
125
|
}
|
|
88
126
|
/** The context passed to an imperative orchestration function. */
|
|
89
127
|
export interface WorkflowContext {
|
package/dist/xml.d.ts
CHANGED
|
@@ -8,4 +8,7 @@ export declare const messageName: (workflowId: string, step: string) => string;
|
|
|
8
8
|
export declare const orchestrateType: (workflowId: string) => string;
|
|
9
9
|
export declare function assertIdent(kind: string, value: string): void;
|
|
10
10
|
export declare function assertJobType(kind: string, value: string): void;
|
|
11
|
+
export declare function assertTimerDuration(kind: string, value: string): void;
|
|
12
|
+
export declare function assertTimerCycle(kind: string, value: string): void;
|
|
13
|
+
export declare function assertTimerDate(kind: string, value: string): void;
|
|
11
14
|
export declare function assertWorkflowIds(wf: Workflow): void;
|
package/dist/xml.js
CHANGED
|
@@ -29,6 +29,56 @@ export function assertJobType(kind, value) {
|
|
|
29
29
|
throw new Error(`${kind} "${value}" is not a valid job type (expected a non-empty token of letters, digits, and _ . : + -)`);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
// --- Timer definitions (BPMN timerEventDefinition bodies) --------------------
|
|
33
|
+
//
|
|
34
|
+
// The engine (engine-core/src/bpmn.rs) accepts a nested `timeDuration`,
|
|
35
|
+
// `timeCycle`, or `timeDate` as either a static ISO-8601 literal or a FEEL
|
|
36
|
+
// expression (a leading `=`). We validate the literal forms at authoring time so
|
|
37
|
+
// a malformed timer fails fast in the DSL rather than at deploy.
|
|
38
|
+
/** An ISO-8601 duration with integer week/day/hour/minute/second components
|
|
39
|
+
* (no years/months — ambiguous length — matching the engine's parser). */
|
|
40
|
+
const ISO_DURATION = /^P(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/;
|
|
41
|
+
function isIsoDuration(v) {
|
|
42
|
+
if (!ISO_DURATION.test(v) || v === "P" || v === "PT" || v.endsWith("T"))
|
|
43
|
+
return false;
|
|
44
|
+
return /\d/.test(v);
|
|
45
|
+
}
|
|
46
|
+
/** A recurring ISO-8601 interval: an optional `R[n]/` repeat prefix + a
|
|
47
|
+
* duration, or a bare duration (the engine repeats it unboundedly). */
|
|
48
|
+
function isIsoCycle(v) {
|
|
49
|
+
const slash = v.indexOf("/");
|
|
50
|
+
if (slash < 0)
|
|
51
|
+
return isIsoDuration(v);
|
|
52
|
+
const repeat = v.slice(0, slash);
|
|
53
|
+
const interval = v.slice(slash + 1);
|
|
54
|
+
return /^R\d*$/.test(repeat) && isIsoDuration(interval);
|
|
55
|
+
}
|
|
56
|
+
/** An ISO-8601 date-time instant (a `timeDate`). A `timeDate` is an absolute
|
|
57
|
+
* instant, so a zone designator (`Z` or an explicit `±HH:MM` offset) is
|
|
58
|
+
* required — a bare local datetime like `2026-01-01T09:00:00` is ambiguous. */
|
|
59
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
60
|
+
/** A FEEL expression body (a leading `=`, non-empty). */
|
|
61
|
+
function isFeel(v) {
|
|
62
|
+
return v.startsWith("=") && v.length > 1;
|
|
63
|
+
}
|
|
64
|
+
export function assertTimerDuration(kind, value) {
|
|
65
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
66
|
+
if (v === "" || (!isFeel(v) && !isIsoDuration(v))) {
|
|
67
|
+
throw new Error(`${kind} "${value}" is not an ISO-8601 duration (e.g. PT1M30S, P1DT6H) or a FEEL expression (=...)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function assertTimerCycle(kind, value) {
|
|
71
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
72
|
+
if (v === "" || (!isFeel(v) && !isIsoCycle(v))) {
|
|
73
|
+
throw new Error(`${kind} "${value}" is not an ISO-8601 repeating interval (e.g. R/PT1H, R5/PT30M), a bare duration (e.g. PT1H), or a FEEL expression (=...)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function assertTimerDate(kind, value) {
|
|
77
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
78
|
+
if (v === "" || (!isFeel(v) && !ISO_DATE.test(v))) {
|
|
79
|
+
throw new Error(`${kind} "${value}" is not an ISO-8601 instant (e.g. 2026-01-01T09:00:00Z) or a FEEL expression (=...)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
32
82
|
export function assertWorkflowIds(wf) {
|
|
33
83
|
assertIdent("workflow id", wf.id);
|
|
34
84
|
if (wf.kind === "declarative") {
|
|
@@ -42,6 +92,9 @@ function assertNodeNames(nodes) {
|
|
|
42
92
|
case "signal":
|
|
43
93
|
assertIdent("step name", node.name);
|
|
44
94
|
break;
|
|
95
|
+
case "timer":
|
|
96
|
+
assertIdent("step name", node.name);
|
|
97
|
+
break;
|
|
45
98
|
case "task":
|
|
46
99
|
assertIdent("step name", node.name);
|
|
47
100
|
if (node.jobType !== undefined)
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/jwulf/nano-ide.git",
|
|
9
|
+
"url": "git+https://github.com/jwulf/nano-ide.git",
|
|
10
10
|
"directory": "packages/workflow"
|
|
11
11
|
},
|
|
12
12
|
"main": "./dist/index.js",
|
package/src/declarative.ts
CHANGED
|
@@ -44,9 +44,10 @@ import type {
|
|
|
44
44
|
StepContract,
|
|
45
45
|
StepHandler,
|
|
46
46
|
SwitchCase,
|
|
47
|
+
TimerStart,
|
|
47
48
|
} from "./types.js";
|
|
48
49
|
import type { Envelope, EnvelopeField } from "./envelope.js";
|
|
49
|
-
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
50
|
+
import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
|
|
50
51
|
|
|
51
52
|
// --- Authoring surface -------------------------------------------------------
|
|
52
53
|
|
|
@@ -100,6 +101,26 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
100
101
|
* payload envelope, if any, comes from the contract's `in`.
|
|
101
102
|
*/
|
|
102
103
|
signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
|
|
104
|
+
/**
|
|
105
|
+
* A durable wait for a point in time (a BPMN timer intermediate catch event).
|
|
106
|
+
* Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
|
|
107
|
+
* FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
|
|
108
|
+
* absolute ISO-8601 instant (or FEEL expression). The engine holds the token
|
|
109
|
+
* durably until the timer fires, then continues. Use it for in-flow delays and
|
|
110
|
+
* scheduled continuations (e.g. "wait 24h, then re-poll").
|
|
111
|
+
*/
|
|
112
|
+
timer<K extends string>(name: K, opts: { after: string } | { at: string }): FlowBuilder<C>;
|
|
113
|
+
/**
|
|
114
|
+
* Make this flow's start event a durable TIMER start rather than an explicit
|
|
115
|
+
* `client.start(...)`. Must be the first statement, at the top level. Pass
|
|
116
|
+
* exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
|
|
117
|
+
* `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
|
|
118
|
+
* from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
|
|
119
|
+
* is the model-native, durable, single-fire-per-cluster replacement for an
|
|
120
|
+
* app-side cron: the schedule lives in the deployable model, and the engine
|
|
121
|
+
* (not each app replica) owns firing it exactly once.
|
|
122
|
+
*/
|
|
123
|
+
startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C>;
|
|
103
124
|
/**
|
|
104
125
|
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
105
126
|
* expression (usually a variable name); each case routes when `subject` equals
|
|
@@ -132,6 +153,8 @@ interface BuilderCtx {
|
|
|
132
153
|
handlers: Record<string, StepHandler>;
|
|
133
154
|
seen: Set<string>;
|
|
134
155
|
loopDepth: number;
|
|
156
|
+
/** Set by `startOn()` (root builder only); lifted onto the flow. */
|
|
157
|
+
startTimer?: TimerStart;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
@@ -163,7 +186,12 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
|
|
|
163
186
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
164
187
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
165
188
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
166
|
-
function makeBuilder<C extends FlowContracts>(
|
|
189
|
+
function makeBuilder<C extends FlowContracts>(
|
|
190
|
+
id: string,
|
|
191
|
+
out: FlowNode[],
|
|
192
|
+
ctx: BuilderCtx,
|
|
193
|
+
isRoot = false,
|
|
194
|
+
): FlowBuilder<C> {
|
|
167
195
|
const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
|
|
168
196
|
const body: FlowNode[] = [];
|
|
169
197
|
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
@@ -192,6 +220,49 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
|
|
|
192
220
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
193
221
|
return b as unknown as FlowBuilder<C>;
|
|
194
222
|
},
|
|
223
|
+
timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
|
|
224
|
+
claimName(ctx, id, name);
|
|
225
|
+
const hasAfter = typeof opts?.after === "string";
|
|
226
|
+
const hasAt = typeof opts?.at === "string";
|
|
227
|
+
if (hasAfter === hasAt) {
|
|
228
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
229
|
+
}
|
|
230
|
+
if (hasAfter) {
|
|
231
|
+
const after = (opts.after as string).trim();
|
|
232
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
233
|
+
out.push({ kind: "timer", name, after });
|
|
234
|
+
} else {
|
|
235
|
+
const at = (opts.at as string).trim();
|
|
236
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
237
|
+
out.push({ kind: "timer", name, at });
|
|
238
|
+
}
|
|
239
|
+
return b as unknown as FlowBuilder<C>;
|
|
240
|
+
},
|
|
241
|
+
startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
|
|
242
|
+
if (!isRoot) {
|
|
243
|
+
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
244
|
+
}
|
|
245
|
+
if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
246
|
+
if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
247
|
+
const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
|
|
248
|
+
if (set.length !== 1) {
|
|
249
|
+
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
250
|
+
}
|
|
251
|
+
if (spec.cycle !== undefined) {
|
|
252
|
+
const cycle = spec.cycle.trim();
|
|
253
|
+
assertTimerCycle(`startOn cycle`, cycle);
|
|
254
|
+
ctx.startTimer = { cycle };
|
|
255
|
+
} else if (spec.after !== undefined) {
|
|
256
|
+
const after = spec.after.trim();
|
|
257
|
+
assertTimerDuration(`startOn after`, after);
|
|
258
|
+
ctx.startTimer = { after };
|
|
259
|
+
} else {
|
|
260
|
+
const at = (spec.at as string).trim();
|
|
261
|
+
assertTimerDate(`startOn at`, at);
|
|
262
|
+
ctx.startTimer = { at };
|
|
263
|
+
}
|
|
264
|
+
return b as unknown as FlowBuilder<C>;
|
|
265
|
+
},
|
|
195
266
|
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
196
267
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
197
268
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
@@ -263,9 +334,11 @@ export function defineFlow(
|
|
|
263
334
|
const steps: FlowNode[] = [];
|
|
264
335
|
const handlers: Record<string, StepHandler> = {};
|
|
265
336
|
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
266
|
-
build(makeBuilder<FlowContracts>(id, steps, ctx));
|
|
337
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx, true));
|
|
267
338
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
268
|
-
|
|
339
|
+
const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
|
|
340
|
+
if (ctx.startTimer) flow.startTimer = ctx.startTimer;
|
|
341
|
+
return flow;
|
|
269
342
|
}
|
|
270
343
|
|
|
271
344
|
// --- Tree walkers ------------------------------------------------------------
|
|
@@ -404,6 +477,24 @@ class Compiler {
|
|
|
404
477
|
});
|
|
405
478
|
}
|
|
406
479
|
|
|
480
|
+
private addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void {
|
|
481
|
+
const id = node.name;
|
|
482
|
+
const body =
|
|
483
|
+
node.after !== undefined
|
|
484
|
+
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
485
|
+
: ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
|
|
486
|
+
this.nodes.push({
|
|
487
|
+
id,
|
|
488
|
+
render: (inc, outg) =>
|
|
489
|
+
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
490
|
+
incomingOutgoing(inc, outg) +
|
|
491
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
492
|
+
body +
|
|
493
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
494
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
407
498
|
private addGateway(id: string, name?: string): RenderNode {
|
|
408
499
|
const gwNode: RenderNode = {
|
|
409
500
|
id,
|
|
@@ -441,6 +532,11 @@ class Compiler {
|
|
|
441
532
|
this.recordEnvelope(node.payload);
|
|
442
533
|
return [this.newEdge(node.name)];
|
|
443
534
|
}
|
|
535
|
+
case "timer": {
|
|
536
|
+
this.addTimerCatchEvent(node);
|
|
537
|
+
this.connect(incoming, node.name);
|
|
538
|
+
return [this.newEdge(node.name)];
|
|
539
|
+
}
|
|
444
540
|
case "switch": {
|
|
445
541
|
const id = `Gw_${this.gw++}`;
|
|
446
542
|
const gw = this.addGateway(id, node.subject);
|
|
@@ -494,11 +590,32 @@ class Compiler {
|
|
|
494
590
|
}
|
|
495
591
|
}
|
|
496
592
|
|
|
593
|
+
/** Render the Start event — a plain none start, or a timer start when the
|
|
594
|
+
* flow declared `startOn(...)`. */
|
|
595
|
+
private renderStart(outg: string[]): string {
|
|
596
|
+
const t = this.flow.startTimer;
|
|
597
|
+
if (!t) return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
|
|
598
|
+
const body =
|
|
599
|
+
t.cycle !== undefined
|
|
600
|
+
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
601
|
+
: t.after !== undefined
|
|
602
|
+
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
603
|
+
: `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
|
|
604
|
+
return (
|
|
605
|
+
` <bpmn:startEvent id="Start">\n` +
|
|
606
|
+
` ${outgoingOnly(outg)}\n` +
|
|
607
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
608
|
+
` ${body}\n` +
|
|
609
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
610
|
+
` </bpmn:startEvent>`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
497
614
|
compile(): string {
|
|
498
615
|
// Start → top-level sequence → End.
|
|
499
616
|
this.nodes.push({
|
|
500
617
|
id: "Start",
|
|
501
|
-
render: (_inc, outg) =>
|
|
618
|
+
render: (_inc, outg) => this.renderStart(outg),
|
|
502
619
|
});
|
|
503
620
|
const s0 = this.newEdge("Start");
|
|
504
621
|
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -53,12 +53,34 @@ export type FlowNode =
|
|
|
53
53
|
| { kind: "run"; name: string; envelopes?: NodeEnvelopes }
|
|
54
54
|
| { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
|
|
55
55
|
| { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
|
|
56
|
+
| ({ kind: "timer"; name: string } & TimerAt)
|
|
56
57
|
| { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
|
|
57
58
|
| { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
|
|
58
59
|
| { kind: "loop"; body: FlowNode[] }
|
|
59
60
|
| { kind: "break" }
|
|
60
61
|
| { kind: "continue" };
|
|
61
62
|
|
|
63
|
+
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
64
|
+
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
|
65
|
+
* Encoded as an XOR so downstream consumers can rely on exactly-one. */
|
|
66
|
+
export type TimerAt =
|
|
67
|
+
| { after: string; at?: never }
|
|
68
|
+
| { at: string; after?: never };
|
|
69
|
+
|
|
70
|
+
/** A flow's start-timer: the plain none start event becomes a durable timer
|
|
71
|
+
* start the engine fires on schedule. Exactly one of:
|
|
72
|
+
* - `cycle` — a recurring ISO-8601 interval (`R/PT1H`, `R5/PT30M`) or bare
|
|
73
|
+
* duration; the engine re-fires on each period. This is the model-native,
|
|
74
|
+
* durable, single-fire-per-cluster replacement for an app-side cron.
|
|
75
|
+
* - `after` — a one-shot ISO-8601 delay (`PT10S`) measured from deployment.
|
|
76
|
+
* - `at` — a one-shot absolute instant (an ISO-8601 date-time, or a FEEL
|
|
77
|
+
* `=` expression).
|
|
78
|
+
* Encoded as an XOR so the exactly-one invariant holds at the type level. */
|
|
79
|
+
export type TimerStart =
|
|
80
|
+
| { cycle: string; after?: never; at?: never }
|
|
81
|
+
| { after: string; cycle?: never; at?: never }
|
|
82
|
+
| { at: string; cycle?: never; after?: never };
|
|
83
|
+
|
|
62
84
|
/** One case of a `switch`: routed when `subject = value` (FEEL equality). */
|
|
63
85
|
export interface SwitchCase {
|
|
64
86
|
value: string;
|
|
@@ -75,6 +97,9 @@ export interface DeclarativeFlow {
|
|
|
75
97
|
/** The flow's node tree (top-level sequence). */
|
|
76
98
|
steps: FlowNode[];
|
|
77
99
|
handlers: Record<string, StepHandler>;
|
|
100
|
+
/** When set, the flow's start event is a durable timer start (see
|
|
101
|
+
* {@link TimerStart}) rather than a plain none start. */
|
|
102
|
+
startTimer?: TimerStart;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
// --- Imperative surface (Strategy B: a replayed orchestration function) -------
|
package/src/xml.ts
CHANGED
|
@@ -40,6 +40,68 @@ export function assertJobType(kind: string, value: string): void {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// --- Timer definitions (BPMN timerEventDefinition bodies) --------------------
|
|
44
|
+
//
|
|
45
|
+
// The engine (engine-core/src/bpmn.rs) accepts a nested `timeDuration`,
|
|
46
|
+
// `timeCycle`, or `timeDate` as either a static ISO-8601 literal or a FEEL
|
|
47
|
+
// expression (a leading `=`). We validate the literal forms at authoring time so
|
|
48
|
+
// a malformed timer fails fast in the DSL rather than at deploy.
|
|
49
|
+
|
|
50
|
+
/** An ISO-8601 duration with integer week/day/hour/minute/second components
|
|
51
|
+
* (no years/months — ambiguous length — matching the engine's parser). */
|
|
52
|
+
const ISO_DURATION = /^P(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/;
|
|
53
|
+
function isIsoDuration(v: string): boolean {
|
|
54
|
+
if (!ISO_DURATION.test(v) || v === "P" || v === "PT" || v.endsWith("T")) return false;
|
|
55
|
+
return /\d/.test(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A recurring ISO-8601 interval: an optional `R[n]/` repeat prefix + a
|
|
59
|
+
* duration, or a bare duration (the engine repeats it unboundedly). */
|
|
60
|
+
function isIsoCycle(v: string): boolean {
|
|
61
|
+
const slash = v.indexOf("/");
|
|
62
|
+
if (slash < 0) return isIsoDuration(v);
|
|
63
|
+
const repeat = v.slice(0, slash);
|
|
64
|
+
const interval = v.slice(slash + 1);
|
|
65
|
+
return /^R\d*$/.test(repeat) && isIsoDuration(interval);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An ISO-8601 date-time instant (a `timeDate`). A `timeDate` is an absolute
|
|
69
|
+
* instant, so a zone designator (`Z` or an explicit `±HH:MM` offset) is
|
|
70
|
+
* required — a bare local datetime like `2026-01-01T09:00:00` is ambiguous. */
|
|
71
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
72
|
+
|
|
73
|
+
/** A FEEL expression body (a leading `=`, non-empty). */
|
|
74
|
+
function isFeel(v: string): boolean {
|
|
75
|
+
return v.startsWith("=") && v.length > 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assertTimerDuration(kind: string, value: string): void {
|
|
79
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
80
|
+
if (v === "" || (!isFeel(v) && !isIsoDuration(v))) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${kind} "${value}" is not an ISO-8601 duration (e.g. PT1M30S, P1DT6H) or a FEEL expression (=...)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function assertTimerCycle(kind: string, value: string): void {
|
|
88
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
89
|
+
if (v === "" || (!isFeel(v) && !isIsoCycle(v))) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${kind} "${value}" is not an ISO-8601 repeating interval (e.g. R/PT1H, R5/PT30M), a bare duration (e.g. PT1H), or a FEEL expression (=...)`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function assertTimerDate(kind: string, value: string): void {
|
|
97
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
98
|
+
if (v === "" || (!isFeel(v) && !ISO_DATE.test(v))) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${kind} "${value}" is not an ISO-8601 instant (e.g. 2026-01-01T09:00:00Z) or a FEEL expression (=...)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
export function assertWorkflowIds(wf: Workflow): void {
|
|
44
106
|
assertIdent("workflow id", wf.id);
|
|
45
107
|
if (wf.kind === "declarative") {
|
|
@@ -54,6 +116,9 @@ function assertNodeNames(nodes: FlowNode[]): void {
|
|
|
54
116
|
case "signal":
|
|
55
117
|
assertIdent("step name", node.name);
|
|
56
118
|
break;
|
|
119
|
+
case "timer":
|
|
120
|
+
assertIdent("step name", node.name);
|
|
121
|
+
break;
|
|
57
122
|
case "task":
|
|
58
123
|
assertIdent("step name", node.name);
|
|
59
124
|
if (node.jobType !== undefined) assertJobType("job type", node.jobType);
|