@nanobpm/urban 0.27.0 → 0.29.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/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/pages.d.ts +6 -0
- package/dist/runtime/core/modules/pages.js +167 -13
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +77 -2
- package/src/runtime/core/modules/pages.ts +171 -13
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -111,3 +111,99 @@ test("validateManifest throws ManifestValidationError with issues", () => {
|
|
|
111
111
|
return true;
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
test("a well-formed instanceTracking binding has no issues", () => {
|
|
116
|
+
const issues = collectManifestIssues({
|
|
117
|
+
...valid,
|
|
118
|
+
instanceTracking: [
|
|
119
|
+
{
|
|
120
|
+
table: "plans",
|
|
121
|
+
keyField: "process_key",
|
|
122
|
+
statusField: "status",
|
|
123
|
+
activeStatuses: ["planning"],
|
|
124
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
assert.deepEqual(issues, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("an instanceTracking binding missing table/keyField/onTerminated.set is flagged", () => {
|
|
132
|
+
const issues = collectManifestIssues({
|
|
133
|
+
...valid,
|
|
134
|
+
instanceTracking: [{ onTerminated: { set: {} } }],
|
|
135
|
+
});
|
|
136
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].table"));
|
|
137
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].keyField"));
|
|
138
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].onTerminated.set"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("instanceTracking activeStatuses without statusField is flagged", () => {
|
|
142
|
+
const issues = collectManifestIssues({
|
|
143
|
+
...valid,
|
|
144
|
+
instanceTracking: [
|
|
145
|
+
{
|
|
146
|
+
table: "plans",
|
|
147
|
+
keyField: "process_key",
|
|
148
|
+
activeStatuses: ["planning"],
|
|
149
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].activeStatuses"));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("instanceTracking activeStatuses with an empty-string statusField is flagged (silently polls every row otherwise)", () => {
|
|
157
|
+
const issues = collectManifestIssues({
|
|
158
|
+
...valid,
|
|
159
|
+
instanceTracking: [
|
|
160
|
+
{
|
|
161
|
+
table: "plans",
|
|
162
|
+
keyField: "process_key",
|
|
163
|
+
statusField: "",
|
|
164
|
+
activeStatuses: ["planning"],
|
|
165
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].activeStatuses"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("instanceTracking pollMs that is non-positive/NaN/non-number is flagged (would hot-loop the poll timer)", () => {
|
|
173
|
+
for (const badPollMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "5000"]) {
|
|
174
|
+
const issues = collectManifestIssues({
|
|
175
|
+
...valid,
|
|
176
|
+
instanceTracking: [
|
|
177
|
+
{
|
|
178
|
+
table: "plans",
|
|
179
|
+
keyField: "process_key",
|
|
180
|
+
statusField: "status",
|
|
181
|
+
activeStatuses: ["planning"],
|
|
182
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
183
|
+
pollMs: badPollMs,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
assert.ok(
|
|
188
|
+
issues.some((i) => i.path === "instanceTracking[0].pollMs"),
|
|
189
|
+
`expected pollMs=${String(badPollMs)} to be flagged`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("instanceTracking with a valid positive pollMs has no pollMs issue", () => {
|
|
195
|
+
const issues = collectManifestIssues({
|
|
196
|
+
...valid,
|
|
197
|
+
instanceTracking: [
|
|
198
|
+
{
|
|
199
|
+
table: "plans",
|
|
200
|
+
keyField: "process_key",
|
|
201
|
+
statusField: "status",
|
|
202
|
+
activeStatuses: ["planning"],
|
|
203
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
204
|
+
pollMs: 5000,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(issues, []);
|
|
209
|
+
});
|
|
@@ -150,6 +150,45 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
|
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// instanceTracking: each binding must name the table + key column it tracks and a
|
|
154
|
+
// non-empty `onTerminated.set` patch — a reconciler with nothing to write is inert
|
|
155
|
+
// and almost certainly a mistake. `activeStatuses` without `statusField` is incoherent
|
|
156
|
+
// (no column to read the statuses from), so the runtime would silently poll every row.
|
|
157
|
+
const tracking = Array.isArray(obj.instanceTracking) ? obj.instanceTracking : undefined;
|
|
158
|
+
if (tracking) {
|
|
159
|
+
tracking.forEach((t, i) => {
|
|
160
|
+
const b = isRecord(t) ? t : undefined;
|
|
161
|
+
if (typeof b?.table !== "string" || b.table.length === 0) {
|
|
162
|
+
issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
|
|
163
|
+
}
|
|
164
|
+
if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
|
|
165
|
+
issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
|
|
166
|
+
}
|
|
167
|
+
const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
|
|
168
|
+
const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
|
|
169
|
+
if (!set || Object.keys(set).length === 0) {
|
|
170
|
+
issues.push({
|
|
171
|
+
path: `instanceTracking[${i}].onTerminated.set`,
|
|
172
|
+
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: `instanceTracking[${i}].activeStatuses`,
|
|
178
|
+
message: "activeStatuses requires statusField",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
182
|
+
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
183
|
+
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
184
|
+
issues.push({
|
|
185
|
+
path: `instanceTracking[${i}].pollMs`,
|
|
186
|
+
message: "pollMs must be a finite positive number of milliseconds",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
return issues;
|
|
154
193
|
}
|
|
155
194
|
|
|
@@ -2,6 +2,7 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
createNanoSdkEngineClient,
|
|
5
|
+
normalizeProcessInstanceState,
|
|
5
6
|
requireProcessInstanceKey,
|
|
6
7
|
SdkEngineClient,
|
|
7
8
|
type NanoSdkActivatedJob,
|
|
@@ -66,6 +67,10 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
|
|
|
66
67
|
calls.push("searchUserTasks");
|
|
67
68
|
return { items: [] };
|
|
68
69
|
},
|
|
70
|
+
async searchProcessInstances() {
|
|
71
|
+
calls.push("searchProcessInstances");
|
|
72
|
+
return { items: [] };
|
|
73
|
+
},
|
|
69
74
|
async completeUserTask() {
|
|
70
75
|
calls.push("completeUserTask");
|
|
71
76
|
return {};
|
|
@@ -202,6 +207,74 @@ test("completeUserTask routes through the SDK", async () => {
|
|
|
202
207
|
assert.deepEqual(seen, { userTaskKey: "utk", variables: { done: true } });
|
|
203
208
|
});
|
|
204
209
|
|
|
210
|
+
test("searchProcessInstances filters by keys ($in) + state with a matching page cap", async () => {
|
|
211
|
+
let seenInput: unknown;
|
|
212
|
+
let seenConsistency: unknown;
|
|
213
|
+
const client = fakeSdkClient({
|
|
214
|
+
searchProcessInstances: async (input, c) => {
|
|
215
|
+
seenInput = input;
|
|
216
|
+
seenConsistency = c;
|
|
217
|
+
return {
|
|
218
|
+
items: [
|
|
219
|
+
{ processInstanceKey: 7, state: "TERMINATED" },
|
|
220
|
+
{ processInstanceKey: "8", state: "ACTIVE" },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const engine = new SdkEngineClient(client);
|
|
226
|
+
const out = await engine.searchProcessInstances({
|
|
227
|
+
processInstanceKeys: ["7", "8", ""], // empty key dropped from the $in
|
|
228
|
+
state: "TERMINATED",
|
|
229
|
+
});
|
|
230
|
+
assert.deepEqual(seenInput, {
|
|
231
|
+
filter: { state: "TERMINATED", processInstanceKey: { $in: ["7", "8"] } },
|
|
232
|
+
page: { limit: 2 },
|
|
233
|
+
});
|
|
234
|
+
assert.deepEqual(seenConsistency, { consistency: { waitUpToMs: 0 } });
|
|
235
|
+
assert.deepEqual(out, [
|
|
236
|
+
{ processInstanceKey: "7", state: "TERMINATED" },
|
|
237
|
+
{ processInstanceKey: "8", state: "ACTIVE" },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("searchProcessInstances with no keys sends no key filter and no page cap", async () => {
|
|
242
|
+
let seenInput: unknown;
|
|
243
|
+
const client = fakeSdkClient({
|
|
244
|
+
searchProcessInstances: async (input) => {
|
|
245
|
+
seenInput = input;
|
|
246
|
+
return { items: [] };
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const engine = new SdkEngineClient(client);
|
|
250
|
+
await engine.searchProcessInstances();
|
|
251
|
+
assert.deepEqual(seenInput, { filter: {} });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("searchProcessInstances skips keyless and unrecognized-state rows", async () => {
|
|
255
|
+
const client = fakeSdkClient({
|
|
256
|
+
searchProcessInstances: async () => ({
|
|
257
|
+
items: [
|
|
258
|
+
{ processInstanceKey: "", state: "TERMINATED" }, // keyless → skipped
|
|
259
|
+
{ processInstanceKey: 9, state: "SUSPENDED" }, // unknown state → skipped
|
|
260
|
+
{ processInstanceKey: 10, state: "completed" }, // case-insensitive → mapped
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
const engine = new SdkEngineClient(client);
|
|
265
|
+
const out = await engine.searchProcessInstances();
|
|
266
|
+
assert.deepEqual(out, [{ processInstanceKey: "10", state: "COMPLETED" }]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
|
|
270
|
+
assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
|
|
271
|
+
assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
|
|
272
|
+
assert.equal(normalizeProcessInstanceState("Terminated"), "TERMINATED");
|
|
273
|
+
assert.equal(normalizeProcessInstanceState("SUSPENDED"), undefined);
|
|
274
|
+
assert.equal(normalizeProcessInstanceState(42), undefined);
|
|
275
|
+
assert.equal(normalizeProcessInstanceState(undefined), undefined);
|
|
276
|
+
});
|
|
277
|
+
|
|
205
278
|
test("registerWorker creates a worker the SDK auto-starts, and dispatches + completes", async () => {
|
|
206
279
|
const client = fakeSdkClient();
|
|
207
280
|
const engine = new SdkEngineClient(client);
|
|
@@ -15,6 +15,8 @@ import type {
|
|
|
15
15
|
EngineClient,
|
|
16
16
|
EngineJob,
|
|
17
17
|
JobHandler,
|
|
18
|
+
ProcessInstanceSnapshot,
|
|
19
|
+
ProcessInstanceState,
|
|
18
20
|
WorkerSubscription,
|
|
19
21
|
} from "../core/host.ts";
|
|
20
22
|
import { isBpmnError } from "../core/host.ts";
|
|
@@ -30,6 +32,26 @@ export function requireProcessInstanceKey(key: string | number | null | undefine
|
|
|
30
32
|
return String(key);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/** Map the engine's process-instance `state` onto the transport-agnostic
|
|
36
|
+
* {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
|
|
37
|
+
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
38
|
+
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
39
|
+
export function normalizeProcessInstanceState(
|
|
40
|
+
raw: unknown,
|
|
41
|
+
): ProcessInstanceState | undefined {
|
|
42
|
+
if (typeof raw !== "string") return undefined;
|
|
43
|
+
switch (raw.toUpperCase()) {
|
|
44
|
+
case "ACTIVE":
|
|
45
|
+
return "ACTIVE";
|
|
46
|
+
case "COMPLETED":
|
|
47
|
+
return "COMPLETED";
|
|
48
|
+
case "TERMINATED":
|
|
49
|
+
return "TERMINATED";
|
|
50
|
+
default:
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
34
56
|
* acknowledgement actions the handler must call. */
|
|
35
57
|
export interface NanoSdkActivatedJob {
|
|
@@ -103,6 +125,11 @@ export interface NanoSdkClient {
|
|
|
103
125
|
input: { userTaskKey: string; variables?: Record<string, unknown> },
|
|
104
126
|
options?: unknown,
|
|
105
127
|
): Promise<unknown>;
|
|
128
|
+
searchProcessInstances(
|
|
129
|
+
input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
|
|
130
|
+
consistency?: unknown,
|
|
131
|
+
options?: unknown,
|
|
132
|
+
): Promise<Record<string, unknown>>;
|
|
106
133
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
107
134
|
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
108
135
|
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
@@ -213,6 +240,42 @@ export class SdkEngineClient implements EngineClient {
|
|
|
213
240
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
214
241
|
}
|
|
215
242
|
|
|
243
|
+
async searchProcessInstances(filter?: {
|
|
244
|
+
processInstanceKeys?: string[];
|
|
245
|
+
state?: ProcessInstanceState;
|
|
246
|
+
}): Promise<ProcessInstanceSnapshot[]> {
|
|
247
|
+
const f: Record<string, unknown> = {};
|
|
248
|
+
if (filter?.state) f.state = filter.state;
|
|
249
|
+
const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
|
|
250
|
+
if (keys && keys.length > 0) f.processInstanceKey = { $in: keys };
|
|
251
|
+
// A process-instance search is an eventually consistent read; ask for zero-wait
|
|
252
|
+
// consistency so it reflects what is currently visible without blocking. Cap the page
|
|
253
|
+
// to the number of keys asked for (each key matches at most one instance), so a bounded
|
|
254
|
+
// reconcile query never silently truncates on the API's default page size.
|
|
255
|
+
const page = keys && keys.length > 0 ? { limit: keys.length } : undefined;
|
|
256
|
+
const body = await this.client.searchProcessInstances(
|
|
257
|
+
{ filter: f, ...(page ? { page } : {}) },
|
|
258
|
+
{ consistency: { waitUpToMs: 0 } },
|
|
259
|
+
);
|
|
260
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
261
|
+
return items.flatMap((it) => {
|
|
262
|
+
const key = it.processInstanceKey;
|
|
263
|
+
if (key == null || key === "") {
|
|
264
|
+
this.log("warn", "skipping process instance with no key in engine response");
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const state = normalizeProcessInstanceState(it.state);
|
|
268
|
+
if (!state) {
|
|
269
|
+
this.log("warn", "skipping process instance with unrecognized state", {
|
|
270
|
+
processInstanceKey: String(key),
|
|
271
|
+
state: String(it.state),
|
|
272
|
+
});
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
return [{ processInstanceKey: String(key), state }];
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
216
279
|
async registerWorker(
|
|
217
280
|
jobType: string,
|
|
218
281
|
handler: JobHandler,
|