@nanobpm/workflow 0.5.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/client.d.ts +88 -23
- package/dist/client.js +62 -67
- package/dist/declarative.d.ts +30 -0
- package/dist/declarative.js +94 -5
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +39 -1
- package/dist/worker.d.ts +37 -11
- package/dist/worker.js +77 -58
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +53 -0
- package/package.json +7 -4
- package/src/client.ts +139 -102
- package/src/declarative.ts +122 -5
- package/src/index.ts +8 -1
- package/src/types.ts +25 -0
- package/src/worker.ts +108 -62
- package/src/xml.ts +65 -0
package/dist/client.d.ts
CHANGED
|
@@ -16,34 +16,101 @@ export declare function toBpmn(wf: Workflow): string;
|
|
|
16
16
|
export declare function toDeployableBpmn(wf: Workflow, opts?: {
|
|
17
17
|
layout?: boolean;
|
|
18
18
|
}): Promise<string>;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The subset of the `@nanobpm/nano-sdk` (Camunda orchestration-cluster) client
|
|
21
|
+
* that the workflow surface uses. `createCamundaClient` returns a superset of
|
|
22
|
+
* this, so a test — or an author bringing their own transport (e.g. the embedded
|
|
23
|
+
* engine) — can inject any object satisfying it.
|
|
24
|
+
*/
|
|
25
|
+
export interface NanoSdkClient {
|
|
26
|
+
createDeployment(input: {
|
|
27
|
+
resources: File[];
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
30
|
+
createProcessInstance(input: {
|
|
31
|
+
processDefinitionId: string;
|
|
32
|
+
variables?: JsonObject;
|
|
33
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
34
|
+
correlateMessage(input: {
|
|
35
|
+
name: string;
|
|
36
|
+
correlationKey: string;
|
|
37
|
+
variables?: JsonObject;
|
|
38
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
39
|
+
getProcessInstance(input: {
|
|
40
|
+
processInstanceKey: string;
|
|
41
|
+
}, consistency: {
|
|
42
|
+
consistency: {
|
|
43
|
+
waitUpToMs: number;
|
|
44
|
+
};
|
|
45
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
46
|
+
createJobWorker(cfg: JobWorkerConfig): NanoJobWorker;
|
|
25
47
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
/** Config for a nano-sdk job worker (the subset the Worker runtime sets). */
|
|
49
|
+
export interface JobWorkerConfig {
|
|
50
|
+
jobType: string;
|
|
51
|
+
jobHandler: (job: ActivatedJob) => Promise<unknown> | unknown;
|
|
52
|
+
workerName?: string;
|
|
53
|
+
maxParallelJobs?: number;
|
|
54
|
+
/** Job lock timeout in ms. */
|
|
55
|
+
jobTimeoutMs?: number;
|
|
56
|
+
/** Long-poll timeout in ms. */
|
|
57
|
+
pollTimeoutMs?: number;
|
|
58
|
+
/** Start polling immediately. The Worker runtime sets this false and starts explicitly. */
|
|
59
|
+
autoStart?: boolean;
|
|
33
60
|
}
|
|
61
|
+
/** The handle returned by `createJobWorker`. */
|
|
62
|
+
export interface NanoJobWorker {
|
|
63
|
+
start(): void;
|
|
64
|
+
stop(): void | Promise<void>;
|
|
65
|
+
stopGracefully?(opts?: {
|
|
66
|
+
waitUpToMs?: number;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
/** An activated job as delivered to a nano-sdk job handler: the workflow `Job`
|
|
70
|
+
* fields plus the acknowledgement actions. */
|
|
71
|
+
export type ActivatedJob = Job & {
|
|
72
|
+
complete(variables?: JsonObject): Promise<unknown>;
|
|
73
|
+
fail(body: {
|
|
74
|
+
errorMessage: string;
|
|
75
|
+
retries?: number;
|
|
76
|
+
}): Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
/** Options common to both ways of building a `WorkflowClient`. */
|
|
79
|
+
interface WorkflowClientCommon {
|
|
80
|
+
/** Auth token for the gateway (CAMUNDA_TOKEN). */
|
|
81
|
+
token?: string;
|
|
82
|
+
/** Transport mode passed to `createCamundaClient`: "auto" | "falcon" | "rest".
|
|
83
|
+
* Default "auto" (Falcon on a Nano server, REST elsewhere). */
|
|
84
|
+
transport?: "auto" | "falcon" | "rest";
|
|
85
|
+
}
|
|
86
|
+
/** Construct a `WorkflowClient` from **either** a `baseUrl` (a nano-sdk client is
|
|
87
|
+
* built for you) **or** a pre-built `client`. The union makes TypeScript enforce
|
|
88
|
+
* that exactly one is supplied, matching the constructor's runtime requirement. */
|
|
89
|
+
export type WorkflowClientOptions = (WorkflowClientCommon & {
|
|
90
|
+
/** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. The
|
|
91
|
+
* nano-sdk client normalises this to the `/v2` REST address. */
|
|
92
|
+
baseUrl: string;
|
|
93
|
+
client?: never;
|
|
94
|
+
}) | (WorkflowClientCommon & {
|
|
95
|
+
/** Inject a pre-built nano-sdk client (or a compatible fake) instead of
|
|
96
|
+
* constructing one from `baseUrl`. Useful for tests, the embedded
|
|
97
|
+
* transport, and advanced authors who build the client themselves. */
|
|
98
|
+
client: NanoSdkClient;
|
|
99
|
+
baseUrl?: never;
|
|
100
|
+
});
|
|
34
101
|
export declare class WorkflowError extends Error {
|
|
35
102
|
readonly status?: number | undefined;
|
|
36
103
|
readonly body?: string | undefined;
|
|
37
104
|
constructor(message: string, status?: number | undefined, body?: string | undefined);
|
|
38
105
|
}
|
|
39
106
|
export declare class WorkflowClient {
|
|
40
|
-
|
|
41
|
-
|
|
107
|
+
/** The underlying nano-sdk client, exposed so the Worker runtime and app
|
|
108
|
+
* authors can reach the engine through the same transport (ADR 0055). The
|
|
109
|
+
* exported `NanoSdkClient` type intentionally models only the subset of
|
|
110
|
+
* methods this package uses (deploy, create, correlate, get, job workers);
|
|
111
|
+
* the runtime value is the full nano-sdk client. */
|
|
112
|
+
readonly sdk: NanoSdkClient;
|
|
42
113
|
constructor(opts: WorkflowClientOptions);
|
|
43
|
-
private json;
|
|
44
|
-
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
45
|
-
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
46
|
-
private send;
|
|
47
114
|
/**
|
|
48
115
|
* Deploy a workflow's derived BPMN model. The deployed model includes
|
|
49
116
|
* auto-generated diagram interchange (DI) so it is inspectable in a
|
|
@@ -64,10 +131,8 @@ export declare class WorkflowClient {
|
|
|
64
131
|
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
65
132
|
* that the gateway silently drops). */
|
|
66
133
|
signal(flow: DeclarativeFlow, signalName: string, correlationKey: string, variables?: JsonObject): Promise<JsonObject>;
|
|
67
|
-
/** Fetch an instance (used by demos/tests to observe completion).
|
|
134
|
+
/** Fetch an instance (used by demos/tests to observe completion). Reads with
|
|
135
|
+
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
68
136
|
getInstance(processInstanceKey: string): Promise<JsonObject | null>;
|
|
69
|
-
activateJobs(type: string, opts: ActivateOptions): Promise<Job[]>;
|
|
70
|
-
completeJob(jobKey: string, variables?: JsonObject): Promise<void>;
|
|
71
|
-
failJob(jobKey: string, errorMessage: string, retries?: number): Promise<void>;
|
|
72
137
|
}
|
|
73
138
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A client for a running nanobpmn gateway, built on `@nanobpm/nano-sdk` (the
|
|
2
|
+
// single engine-transport spine — ADR 0055). It derives a workflow's model,
|
|
3
|
+
// deploys it (with diagram interchange), starts instances, and correlates
|
|
4
|
+
// signals; the low-level job protocol used by the Worker runtime is the SDK's
|
|
5
|
+
// own job worker, reached through the exposed `sdk` client.
|
|
6
|
+
import { createCamundaClient } from "@nanobpm/nano-sdk";
|
|
4
7
|
import { declarativeToBpmn, walkNodes } from "./declarative.js";
|
|
5
8
|
import { imperativeToBpmn } from "./imperative.js";
|
|
6
9
|
import { layoutBpmn } from "./layout.js";
|
|
@@ -57,37 +60,36 @@ export class WorkflowError extends Error {
|
|
|
57
60
|
this.name = "WorkflowError";
|
|
58
61
|
}
|
|
59
62
|
}
|
|
63
|
+
/** Normalise a thrown SDK/transport error into a WorkflowError, preserving an
|
|
64
|
+
* HTTP status when the SDK attached one. */
|
|
65
|
+
function asWorkflowError(e, what) {
|
|
66
|
+
if (e instanceof WorkflowError)
|
|
67
|
+
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);
|
|
71
|
+
}
|
|
60
72
|
export class WorkflowClient {
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
/** The underlying nano-sdk client, exposed so the Worker runtime and app
|
|
74
|
+
* authors can reach the engine through the same transport (ADR 0055). The
|
|
75
|
+
* exported `NanoSdkClient` type intentionally models only the subset of
|
|
76
|
+
* methods this package uses (deploy, create, correlate, get, job workers);
|
|
77
|
+
* the runtime value is the full nano-sdk client. */
|
|
78
|
+
sdk;
|
|
63
79
|
constructor(opts) {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
68
|
-
if (!f)
|
|
69
|
-
throw new Error("no fetch available; pass options.fetch (Node < 18)");
|
|
70
|
-
this.fetchImpl = f;
|
|
71
|
-
}
|
|
72
|
-
async json(path, init, what) {
|
|
73
|
-
const res = await this.send(path, init, what);
|
|
74
|
-
return (await res.json());
|
|
75
|
-
}
|
|
76
|
-
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
77
|
-
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
78
|
-
async send(path, init, what) {
|
|
79
|
-
let res;
|
|
80
|
-
try {
|
|
81
|
-
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
|
82
|
-
}
|
|
83
|
-
catch (e) {
|
|
84
|
-
throw new WorkflowError(`${what} transport error: ${e.message}`);
|
|
80
|
+
if (opts?.client) {
|
|
81
|
+
this.sdk = opts.client;
|
|
82
|
+
return;
|
|
85
83
|
}
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
if (!opts?.baseUrl)
|
|
85
|
+
throw new Error("WorkflowClient needs a baseUrl or a client");
|
|
86
|
+
this.sdk = createCamundaClient({
|
|
87
|
+
config: {
|
|
88
|
+
CAMUNDA_REST_ADDRESS: opts.baseUrl.replace(/\/+$/, ""),
|
|
89
|
+
...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
|
|
90
|
+
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
93
95
|
* Deploy a workflow's derived BPMN model. The deployed model includes
|
|
@@ -98,9 +100,13 @@ export class WorkflowClient {
|
|
|
98
100
|
*/
|
|
99
101
|
async deploy(wf, opts = {}) {
|
|
100
102
|
const xml = await toDeployableBpmn(wf, opts);
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const file = new File([xml], `${wf.id}.bpmn`, { type: "text/xml" });
|
|
104
|
+
try {
|
|
105
|
+
return (await this.sdk.createDeployment({ resources: [file] }));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
throw asWorkflowError(e, "deploy");
|
|
109
|
+
}
|
|
104
110
|
}
|
|
105
111
|
/**
|
|
106
112
|
* Start a workflow instance. For imperative workflows the engine variables are
|
|
@@ -109,11 +115,15 @@ export class WorkflowClient {
|
|
|
109
115
|
*/
|
|
110
116
|
async start(wf, input = {}) {
|
|
111
117
|
const variables = wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
try {
|
|
119
|
+
return (await this.sdk.createProcessInstance({
|
|
120
|
+
processDefinitionId: wf.id,
|
|
121
|
+
variables,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw asWorkflowError(e, "start");
|
|
126
|
+
}
|
|
117
127
|
}
|
|
118
128
|
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
119
129
|
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
@@ -129,40 +139,25 @@ export class WorkflowClient {
|
|
|
129
139
|
if (!signals.includes(signalName)) {
|
|
130
140
|
throw new WorkflowError(`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"}`);
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
try {
|
|
143
|
+
return (await this.sdk.correlateMessage({
|
|
144
|
+
name: messageName(flow.id, signalName),
|
|
145
|
+
correlationKey,
|
|
146
|
+
variables,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
throw asWorkflowError(e, `signal "${signalName}"`);
|
|
151
|
+
}
|
|
137
152
|
}
|
|
138
|
-
/** Fetch an instance (used by demos/tests to observe completion).
|
|
153
|
+
/** Fetch an instance (used by demos/tests to observe completion). Reads with
|
|
154
|
+
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
139
155
|
async getInstance(processInstanceKey) {
|
|
140
156
|
try {
|
|
141
|
-
|
|
142
|
-
if (!res.ok)
|
|
143
|
-
return null;
|
|
144
|
-
return (await res.json());
|
|
157
|
+
return (await this.sdk.getProcessInstance({ processInstanceKey }, { consistency: { waitUpToMs: 0 } }));
|
|
145
158
|
}
|
|
146
159
|
catch {
|
|
147
160
|
return null;
|
|
148
161
|
}
|
|
149
162
|
}
|
|
150
|
-
// --- low-level job protocol (used by the Worker runtime) -------------------
|
|
151
|
-
async activateJobs(type, opts) {
|
|
152
|
-
const body = {
|
|
153
|
-
type,
|
|
154
|
-
worker: opts.worker,
|
|
155
|
-
maxJobsToActivate: opts.maxJobsToActivate ?? 1,
|
|
156
|
-
timeout: opts.timeout ?? 30000,
|
|
157
|
-
requestTimeout: opts.requestTimeout ?? 15000,
|
|
158
|
-
};
|
|
159
|
-
const res = await this.json(`/v2/jobs/activation`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, `activate ${type}`);
|
|
160
|
-
return res.jobs ?? [];
|
|
161
|
-
}
|
|
162
|
-
async completeJob(jobKey, variables = {}) {
|
|
163
|
-
await this.send(`/v2/jobs/${jobKey}/completion`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) }, "complete job");
|
|
164
|
-
}
|
|
165
|
-
async failJob(jobKey, errorMessage, retries = 0) {
|
|
166
|
-
await this.send(`/v2/jobs/${jobKey}/failure`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ errorMessage, retries }) }, "fail job");
|
|
167
|
-
}
|
|
168
163
|
}
|
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
|
@@ -6,7 +6,7 @@ export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
|
|
|
6
6
|
export { envelope } from "./envelope.js";
|
|
7
7
|
export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
|
|
8
8
|
export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
|
|
9
|
-
export type { WorkflowClientOptions,
|
|
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/worker.d.ts
CHANGED
|
@@ -9,36 +9,53 @@ export interface ActivityEvent {
|
|
|
9
9
|
/** For an imperative orchestrator turn: the journalled step key, or "__done". */
|
|
10
10
|
step?: string;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
baseUrl?: string;
|
|
15
|
-
client?: WorkflowClient;
|
|
12
|
+
/** Options common to both ways of building a `Worker`. */
|
|
13
|
+
interface WorkerCommon {
|
|
16
14
|
workflows: Workflow[];
|
|
17
15
|
/** Worker name reported to the gateway. Default "nanobpm-workflow". */
|
|
18
16
|
name?: string;
|
|
17
|
+
/** Transport for the worker's own client (used only when `client` is not
|
|
18
|
+
* supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
|
|
19
|
+
* resilience-critical, and REST long-poll reconnects with backoff after an
|
|
20
|
+
* engine restart (the ADR 0044 property), whereas the Falcon push transport
|
|
21
|
+
* does not recover a mid-stream disconnect. Instance creation, by contrast,
|
|
22
|
+
* defaults to Falcon on its own `WorkflowClient` for throughput. */
|
|
23
|
+
transport?: "auto" | "falcon" | "rest";
|
|
19
24
|
/** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
|
|
20
25
|
pollTimeoutMs?: number;
|
|
21
26
|
/** Job lock timeout, ms. Default 30000. */
|
|
22
27
|
jobTimeoutMs?: number;
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/** Max jobs handled in parallel per derived job type. Default 1. */
|
|
29
|
+
maxParallelJobs?: number;
|
|
25
30
|
onActivity?: (e: ActivityEvent) => void | Promise<void>;
|
|
26
31
|
onError?: (err: Error, context: {
|
|
27
32
|
type: string;
|
|
28
33
|
}) => void;
|
|
29
34
|
}
|
|
35
|
+
/** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
|
|
36
|
+
* **or** an existing `client`. The union makes TypeScript enforce that exactly
|
|
37
|
+
* one is supplied, matching the constructor's runtime requirement. */
|
|
38
|
+
export type WorkerOptions = (WorkerCommon & {
|
|
39
|
+
/** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
client?: never;
|
|
42
|
+
}) | (WorkerCommon & {
|
|
43
|
+
/** An existing `WorkflowClient` to serve jobs through. */
|
|
44
|
+
client: WorkflowClient;
|
|
45
|
+
baseUrl?: never;
|
|
46
|
+
});
|
|
30
47
|
export declare class Worker {
|
|
31
48
|
private readonly client;
|
|
32
49
|
private readonly name;
|
|
33
50
|
private readonly pollTimeoutMs;
|
|
34
51
|
private readonly jobTimeoutMs;
|
|
35
|
-
private readonly
|
|
52
|
+
private readonly maxParallelJobs;
|
|
36
53
|
private readonly onActivity?;
|
|
37
54
|
private readonly onError?;
|
|
38
55
|
/** job type → { workflowId, handle } */
|
|
39
56
|
private readonly routes;
|
|
40
57
|
private running;
|
|
41
|
-
private
|
|
58
|
+
private workers;
|
|
42
59
|
constructor(opts: WorkerOptions);
|
|
43
60
|
private register;
|
|
44
61
|
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
@@ -55,9 +72,18 @@ export declare class Worker {
|
|
|
55
72
|
private emitActivity;
|
|
56
73
|
/** The derived job types this worker serves. */
|
|
57
74
|
get servedTypes(): string[];
|
|
58
|
-
/** Begin
|
|
75
|
+
/** Begin serving. Creates one nano-sdk job worker per derived job type with
|
|
76
|
+
* `autoStart: false`, then starts each explicitly, so the poll lifecycle is
|
|
77
|
+
* deterministic and owned here rather than left to the SDK's auto-start
|
|
78
|
+
* default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
|
|
79
|
+
* before `start()` is called. */
|
|
59
80
|
start(): void;
|
|
60
|
-
/** Stop
|
|
81
|
+
/** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
|
|
82
|
+
* per worker: the Falcon worker's `stop` throws if it never bound a transport
|
|
83
|
+
* (detection still pending), which must not fail the whole shutdown. */
|
|
61
84
|
stop(): Promise<void>;
|
|
62
|
-
|
|
85
|
+
/** Dispatch one activated job to its route handler and acknowledge it. Returns
|
|
86
|
+
* the nano-sdk action receipt (its `complete`/`fail` result). */
|
|
87
|
+
private handleJob;
|
|
63
88
|
}
|
|
89
|
+
export {};
|