@nanobpm/workflow 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nanobpm/workflow
2
2
 
3
- Code-first durable orchestration for [nanobpmn](https://github.com/Magikcraft/nano-bpm)
3
+ Code-first durable orchestration for [nanobpmn](https://nanobpm.io)
4
4
  (**ADR 0044**). Author durable workflows as ordinary async code; the SDK derives
5
5
  the executable BPMN model, the job types, and the message/correlation wiring, and
6
6
  hosts a generic worker. No diagram, no task-type wiring, no correlation plumbing
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<Record<string, unknown>>;
29
+ }, options?: unknown): Promise<unknown>;
30
30
  createProcessInstance(input: {
31
31
  processDefinitionId: string;
32
32
  variables?: JsonObject;
33
- }, options?: unknown): Promise<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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?.cause;
41
- if (cause?.code !== "ERR_MODULE_NOT_FOUND")
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 err = e;
69
- const status = err?.status ?? err?.response?.status;
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
- this.sdk = createCamundaClient({
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;
@@ -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;
@@ -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?.after === "string";
110
- const hasAt = typeof opts?.at === "string";
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 (hasAfter) {
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 set = ["cycle", "after", "at"].filter((k) => typeof spec?.[k] === "string");
135
- if (set.length !== 1) {
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 (spec.cycle !== undefined) {
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 (spec.after !== undefined) {
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 entries = Object.entries(cases).filter(([k]) => k !== "default");
160
- if (entries.length === 0)
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
- const build = (typeof second === "function" ? second : third);
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
- build(makeBuilder(id, steps, ctx, true));
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.at)}</bpmn:timeDate>\n`;
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.at)}</bpmn:timeDate>`;
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
- // `type` is phantom: never read at runtime; the cast keeps the value shape
60
- // minimal while the declared type carries the inference.
61
- return { name, fields: list, type: undefined };
68
+ return new EnvelopeDef(name, list);
62
69
  }
@@ -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
- return journal[key]; // replay: return recorded value, no side effect
84
+ const value = journal[key];
85
+ assertReplayValue(value, name);
86
+ return value; // replay: return recorded value, no side effect
74
87
  }
75
- const result = ((await fn()) ?? null); // frontier: the ONE real side effect this turn
76
- const carrier = new Error("nanobpm.workflow suspend");
77
- carrier[SUSPEND] = { key, result };
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
- const s = e[SUSPEND];
87
- if (s)
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?.code !== "ERR_MODULE_NOT_FOUND") {
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 = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
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
- return { variables: { wfDone: true }, step: "__done" };
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
- return { variables: { journal: next, wfDone: false }, step: step.frontier.key };
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) => ({ variables: ((await handler(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,12 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": "git+https://github.com/jwulf/nano-ide.git",
9
+ "url": "git+https://github.com/nanobpm/nano-ide.git",
10
10
  "directory": "packages/workflow"
11
11
  },
12
12
  "main": "./dist/index.js",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "sideEffects": false,
30
30
  "dependencies": {
31
- "@nanobpm/nano-sdk": "^1.2.3"
31
+ "@nanobpm/nano-sdk": "^1.2.5"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
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 as { cause?: NodeJS.ErrnoException })?.cause;
47
- if (cause?.code !== "ERR_MODULE_NOT_FOUND") throw e;
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<Record<string, unknown>>;
119
+ ): Promise<unknown>;
73
120
  createProcessInstance(
74
121
  input: { processDefinitionId: string; variables?: JsonObject },
75
122
  options?: unknown,
76
- ): Promise<Record<string, unknown>>;
123
+ ): Promise<unknown>;
77
124
  correlateMessage(
78
125
  input: { name: string; correlationKey: string; variables?: JsonObject },
79
126
  options?: unknown,
80
- ): Promise<Record<string, unknown>>;
127
+ ): Promise<unknown>;
81
128
  getProcessInstance(
82
129
  input: { processInstanceKey: string },
83
130
  consistency: { consistency: { waitUpToMs: number } },
84
131
  options?: unknown,
85
- ): Promise<Record<string, unknown>>;
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 err = e as { message?: string; status?: number; response?: { status?: number } };
160
- const status = err?.status ?? err?.response?.status;
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
- this.sdk = createCamundaClient({
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
- }) as unknown as NanoSdkClient;
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] })) as DeployResult;
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
- })) as StartResult;
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
- })) as JsonObject;
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
- )) as JsonObject;
344
+ ));
263
345
  } catch {
264
346
  return null;
265
347
  }
@@ -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(name: string, handler: StepHandler): FlowBuilder<C> {
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
296
+ return b;
279
297
  },
280
- timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
298
+ timer(name: string, opts: { after: string } | { at: string }): FlowBuilder<C> {
281
299
  claimName(ctx, id, name);
282
- const hasAfter = typeof opts?.after === "string";
283
- const hasAt = typeof opts?.at === "string";
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 (hasAfter) {
288
- const after = (opts.after as string).trim();
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 = (opts.at as string).trim();
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 as unknown as FlowBuilder<C>;
314
+ return b;
297
315
  },
298
- startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
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 set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
305
- if (set.length !== 1) {
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 (spec.cycle !== undefined) {
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 (spec.after !== undefined) {
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 = (spec.at as string).trim();
335
+ const at = spec.at.trim();
318
336
  assertTimerDate(`startOn at`, at);
319
337
  ctx.startTimer = { at };
320
338
  }
321
- return b as unknown as FlowBuilder<C>;
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 entries = Object.entries(cases).filter(([k]) => k !== "default") as [string, Block<C>][];
328
- if (entries.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
329
- const caseNodes: SwitchCase[] = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
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 as unknown as FlowBuilder<C>;
457
+ return b;
436
458
  },
437
459
  };
438
- return b as unknown as FlowBuilder<C>;
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
- const build = (typeof second === "function" ? second : third) as (w: FlowBuilder<FlowContracts>) => void;
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
- build(makeBuilder<FlowContracts>(id, steps, ctx, true));
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.at as string)}</bpmn:timeDate>\n`;
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.at as string)}</bpmn:timeDate>`;
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
- // `type` is phantom: never read at runtime; the cast keeps the value shape
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
- interface SuspendCarrier {
68
- [SUSPEND]?: { key: string; result: Json };
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
- return journal[key] as T; // replay: return recorded value, no side effect
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 = ((await fn()) ?? null) as T; // frontier: the ONE real side effect this turn
98
- const carrier = new Error("nanobpm.workflow suspend") as Error & SuspendCarrier;
99
- carrier[SUSPEND] = { key, result };
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
- const s = (e as SuspendCarrier)[SUSPEND];
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 as NodeJS.ErrnoException | undefined)?.code !== "ERR_MODULE_NOT_FOUND") {
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 = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
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 as JsonObject) ?? {};
105
- const journal = (job.variables.journal as JsonObject) ?? {};
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) return { variables: { wfDone: true } as JsonObject, 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
- return { variables: { journal: next, wfDone: false } as JsonObject, step: step.frontier.key };
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) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
140
+ handle: async (job) => {
141
+ const variables: JsonObject = (await handler(job)) ?? {};
142
+ return { variables };
143
+ },
133
144
  });
134
145
  });
135
146
  }