@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.
@@ -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
@@ -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
- return { kind: "declarative", id, steps, handlers };
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) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
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.6.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",
@@ -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>(id: string, out: FlowNode[], ctx: BuilderCtx): FlowBuilder<C> {
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
- return { kind: "declarative", id, steps, handlers };
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) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
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
@@ -73,6 +73,7 @@ export type {
73
73
  DeclarativeStep,
74
74
  FlowNode,
75
75
  SwitchCase,
76
+ TimerStart,
76
77
  NodeEnvelopes,
77
78
  StepContract,
78
79
  FlowContracts,
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);