@nanobpm/urban 0.28.0 → 0.30.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/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- 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/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +23 -3
- package/dist/runtime/core/modules/datasource.js +61 -6
- 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 +22 -0
- package/dist/runtime/core/modules/instance-tracking.js +158 -0
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/surfaces.js +9 -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 +90 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- 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/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -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 +77 -6
- 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 +180 -0
- package/src/runtime/core/modules/pages.test.ts +47 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- 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/surfaces.ts +10 -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 +118 -5
- package/src/runtime/engine/nanosdk.ts +102 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- package/src/toolkit/index.ts +41 -1
|
@@ -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);
|
|
@@ -266,7 +339,41 @@ test("registerWorker fails the job when the handler throws", async () => {
|
|
|
266
339
|
};
|
|
267
340
|
await rec.dispatch(job);
|
|
268
341
|
assert.equal(failBody?.errorMessage, "boom");
|
|
269
|
-
|
|
342
|
+
// No retry count is pinned: the SDK decrements the job's remaining retries
|
|
343
|
+
// (`job.retries - 1`), so a transient handler failure self-heals on redelivery and
|
|
344
|
+
// only parks as an incident once the budget is exhausted.
|
|
345
|
+
assert.equal(failBody?.retries, undefined);
|
|
346
|
+
assert.equal("retries" in (failBody ?? {}), false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("registerWorker leaves a job for redelivery when complete() fails (transport error, not a handler bug)", async () => {
|
|
350
|
+
const client = fakeSdkClient();
|
|
351
|
+
const engine = new SdkEngineClient(client);
|
|
352
|
+
let handlerRan = false;
|
|
353
|
+
await engine.registerWorker("svc", () => {
|
|
354
|
+
handlerRan = true;
|
|
355
|
+
return { ok: true };
|
|
356
|
+
});
|
|
357
|
+
const rec = client.workers[0];
|
|
358
|
+
let failCalled = false;
|
|
359
|
+
const job: NanoSdkActivatedJob = {
|
|
360
|
+
jobKey: "j1",
|
|
361
|
+
variables: {},
|
|
362
|
+
// The handler succeeded, but reporting the result to the engine fails transiently.
|
|
363
|
+
async complete() {
|
|
364
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
365
|
+
},
|
|
366
|
+
async fail() {
|
|
367
|
+
failCalled = true;
|
|
368
|
+
throw new Error("must not hard-park a job whose handler succeeded");
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
// Must not throw, and must NOT call fail(retries:0) — the job is left locked for the
|
|
372
|
+
// engine to redeliver on lock timeout.
|
|
373
|
+
const result = await rec.dispatch(job);
|
|
374
|
+
assert.equal(handlerRan, true);
|
|
375
|
+
assert.equal(failCalled, false);
|
|
376
|
+
assert.equal(result, undefined);
|
|
270
377
|
});
|
|
271
378
|
|
|
272
379
|
test("registerWorker routes a thrown BpmnError to the engine's error()", async () => {
|
|
@@ -303,14 +410,14 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
|
|
|
303
410
|
throw new BpmnError("NOT_FOUND", "no such record");
|
|
304
411
|
});
|
|
305
412
|
const rec = client.workers[0];
|
|
306
|
-
let failBody: { errorMessage: string } | undefined;
|
|
413
|
+
let failBody: { errorMessage: string; retries?: number } | undefined;
|
|
307
414
|
const job: NanoSdkActivatedJob = {
|
|
308
415
|
jobKey: "j1",
|
|
309
416
|
variables: {},
|
|
310
417
|
async complete() {
|
|
311
418
|
throw new Error("should not complete");
|
|
312
419
|
},
|
|
313
|
-
async fail(body: { errorMessage: string }) {
|
|
420
|
+
async fail(body: { errorMessage: string; retries?: number }) {
|
|
314
421
|
failBody = body;
|
|
315
422
|
return "failed";
|
|
316
423
|
},
|
|
@@ -318,6 +425,9 @@ test("registerWorker falls back to fail() for a BpmnError when the SDK has no er
|
|
|
318
425
|
};
|
|
319
426
|
await rec.dispatch(job);
|
|
320
427
|
assert.equal(failBody?.errorMessage, "no such record");
|
|
428
|
+
// A BpmnError is a modelled, deterministic outcome: pin retries:0 so it does NOT consume the
|
|
429
|
+
// retry budget (unlike a generic handler failure, which omits retries to self-heal).
|
|
430
|
+
assert.equal(failBody?.retries, 0);
|
|
321
431
|
});
|
|
322
432
|
|
|
323
433
|
test("registerWorker falls back to fail() when the BPMN error report itself throws", async () => {
|
|
@@ -327,7 +437,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
|
|
|
327
437
|
throw new BpmnError("NOT_FOUND", "no such record");
|
|
328
438
|
});
|
|
329
439
|
const rec = client.workers[0];
|
|
330
|
-
let failBody: { errorMessage: string } | undefined;
|
|
440
|
+
let failBody: { errorMessage: string; retries?: number } | undefined;
|
|
331
441
|
const job: NanoSdkActivatedJob = {
|
|
332
442
|
jobKey: "j1",
|
|
333
443
|
variables: {},
|
|
@@ -337,7 +447,7 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
|
|
|
337
447
|
async error() {
|
|
338
448
|
throw new Error("transport down");
|
|
339
449
|
},
|
|
340
|
-
async fail(body: { errorMessage: string }) {
|
|
450
|
+
async fail(body: { errorMessage: string; retries?: number }) {
|
|
341
451
|
failBody = body;
|
|
342
452
|
return "failed";
|
|
343
453
|
},
|
|
@@ -345,6 +455,9 @@ test("registerWorker falls back to fail() when the BPMN error report itself thro
|
|
|
345
455
|
await rec.dispatch(job);
|
|
346
456
|
// The job must still be acknowledged via fail() rather than silently dropped.
|
|
347
457
|
assert.equal(failBody?.errorMessage, "no such record");
|
|
458
|
+
// A BpmnError is a modelled, deterministic outcome: pin retries:0 so it does NOT consume the
|
|
459
|
+
// retry budget (unlike a generic handler failure, which omits retries to self-heal).
|
|
460
|
+
assert.equal(failBody?.retries, 0);
|
|
348
461
|
});
|
|
349
462
|
|
|
350
463
|
test("close stops every worker and closes the SDK client", async () => {
|
|
@@ -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,
|
|
@@ -237,9 +300,9 @@ export class SdkEngineClient implements EngineClient {
|
|
|
237
300
|
elementId: job.elementId,
|
|
238
301
|
variables: job.variables ?? {},
|
|
239
302
|
};
|
|
303
|
+
let out: Record<string, unknown> | void;
|
|
240
304
|
try {
|
|
241
|
-
|
|
242
|
-
return await job.complete(out ?? {});
|
|
305
|
+
out = await handler(engineJob);
|
|
243
306
|
} catch (err) {
|
|
244
307
|
// A handler-raised BPMN error is a modelled, non-retryable outcome:
|
|
245
308
|
// report it as a BPMN error (routed to an error boundary/event) rather
|
|
@@ -267,16 +330,48 @@ export class SdkEngineClient implements EngineClient {
|
|
|
267
330
|
}
|
|
268
331
|
}
|
|
269
332
|
const message = err instanceof Error ? err.message : String(err);
|
|
270
|
-
this.log("error", `handler ${jobType} threw`, {
|
|
333
|
+
this.log("error", `handler ${jobType} threw`, {
|
|
334
|
+
err: message,
|
|
335
|
+
jobKey: engineJob.jobKey,
|
|
336
|
+
processInstanceKey: engineJob.processInstanceKey,
|
|
337
|
+
elementId: engineJob.elementId,
|
|
338
|
+
});
|
|
271
339
|
try {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
340
|
+
// A generic handler failure omits the retry count so the SDK decrements the job's
|
|
341
|
+
// remaining retries (`job.retries - 1`): a transient failure (e.g. a fleeting store
|
|
342
|
+
// error) self-heals on redelivery and only parks as an incident once the budget is
|
|
343
|
+
// exhausted. A `BpmnError` reaching here (no `job.error()` transport, or the error
|
|
344
|
+
// report itself threw) is the exception: it is a modelled, DETERMINISTIC outcome, so
|
|
345
|
+
// retrying would re-throw the same error and burn the budget for nothing — pin
|
|
346
|
+
// `retries: 0` to keep it non-retryable. Await + swallow so a rejected `fail` cannot
|
|
347
|
+
// escape as an unhandled rejection.
|
|
348
|
+
const body: { errorMessage: string; retries?: number } = {
|
|
349
|
+
errorMessage: message.slice(0, 500),
|
|
350
|
+
};
|
|
351
|
+
if (isBpmnError(err)) body.retries = 0;
|
|
352
|
+
return await job.fail(body);
|
|
276
353
|
} catch {
|
|
277
354
|
return undefined;
|
|
278
355
|
}
|
|
279
356
|
}
|
|
357
|
+
// The handler resolved: its work is done. Reporting that result to the engine
|
|
358
|
+
// (`complete`) is a SEPARATE concern — a failure here is a transport/engine problem
|
|
359
|
+
// (e.g. a transient store error), NOT a handler bug. Failing it (even with a decrementing
|
|
360
|
+
// retry) would burn the job's retry budget for a completion we can't confirm didn't land.
|
|
361
|
+
// Instead, log and leave the job locked so the engine redelivers it on lock timeout;
|
|
362
|
+
// handlers are idempotent (at-least-once), so a redelivery re-completes cleanly.
|
|
363
|
+
try {
|
|
364
|
+
return await job.complete(out ?? {});
|
|
365
|
+
} catch (completeErr) {
|
|
366
|
+
const message = completeErr instanceof Error ? completeErr.message : String(completeErr);
|
|
367
|
+
this.log("error", `complete ${jobType} failed; leaving job for engine redelivery`, {
|
|
368
|
+
err: message,
|
|
369
|
+
jobKey: engineJob.jobKey,
|
|
370
|
+
processInstanceKey: engineJob.processInstanceKey,
|
|
371
|
+
elementId: engineJob.elementId,
|
|
372
|
+
});
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
280
375
|
},
|
|
281
376
|
});
|
|
282
377
|
// The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
|
package/src/runtime/index.ts
CHANGED
|
@@ -83,8 +83,20 @@ export type {
|
|
|
83
83
|
} from "./core/modules/gateway.ts";
|
|
84
84
|
export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
|
|
85
85
|
export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
|
|
86
|
+
export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
|
|
87
|
+
export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
|
|
86
88
|
export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
|
|
87
89
|
export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
|
|
90
|
+
export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
|
|
91
|
+
export type {
|
|
92
|
+
ApiBinding,
|
|
93
|
+
ApiHandle,
|
|
94
|
+
DefaultContract,
|
|
95
|
+
OperationContract,
|
|
96
|
+
OperationHandler,
|
|
97
|
+
OperationInput,
|
|
98
|
+
OperationResult,
|
|
99
|
+
} from "./core/modules/api.ts";
|
|
88
100
|
export type { AppJobHandler } from "./core/modules/workers.ts";
|
|
89
101
|
export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
|
|
90
102
|
export {
|
package/src/toolkit/artifact.ts
CHANGED
|
@@ -27,3 +27,11 @@ export const GENERATED_DIR = "nano-generated";
|
|
|
27
27
|
export function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[] {
|
|
28
28
|
return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
|
|
32
|
+
* Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
|
|
33
|
+
* shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
|
|
34
|
+
* whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
|
|
35
|
+
export function isAbsolutePath(p: string): boolean {
|
|
36
|
+
return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
|
|
37
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS } from "./api.ts";
|
|
4
|
+
import type { OpenApiDoc } from "../../openapi/spec.ts";
|
|
5
|
+
|
|
6
|
+
const doc: OpenApiDoc = {
|
|
7
|
+
openapi: "3.0.0",
|
|
8
|
+
components: {
|
|
9
|
+
schemas: {
|
|
10
|
+
Invoice: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: { id: { type: "string" }, amount: { type: "integer" }, status: { type: "string", enum: ["draft", "sent"] } },
|
|
13
|
+
required: ["id", "amount"],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
paths: {
|
|
18
|
+
"/invoices/{id}": {
|
|
19
|
+
get: {
|
|
20
|
+
operationId: "getInvoice",
|
|
21
|
+
summary: "Fetch one invoice",
|
|
22
|
+
parameters: [
|
|
23
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
24
|
+
{ name: "expand", in: "query", schema: { type: "string" } },
|
|
25
|
+
],
|
|
26
|
+
responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
test("schemaToTs renders primitives, enums, arrays, refs, and unknown fallback", () => {
|
|
33
|
+
assert.equal(schemaToTs({ type: "string" }), "string");
|
|
34
|
+
assert.equal(schemaToTs({ type: "integer" }), "number");
|
|
35
|
+
assert.equal(schemaToTs({ type: "string", enum: ["a", "b"] }), '"a" | "b"');
|
|
36
|
+
assert.equal(schemaToTs({ type: "array", items: { type: "string" } }), "Array<string>");
|
|
37
|
+
assert.equal(schemaToTs({ $ref: "#/components/schemas/Invoice" }), "Invoice");
|
|
38
|
+
assert.equal(schemaToTs(undefined), "unknown");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("emitApiBindings emits component types + per-op request/response + the id map", () => {
|
|
42
|
+
const out = emitApiBindings(doc);
|
|
43
|
+
assert.match(out, /export type Invoice = \{/);
|
|
44
|
+
assert.match(out, /export interface GetInvoiceRequest \{/);
|
|
45
|
+
assert.match(out, /params: \{ "id": string \}/);
|
|
46
|
+
assert.match(out, /export type GetInvoiceResponse = Invoice;/);
|
|
47
|
+
assert.match(out, /export type ApiOperationId = "getInvoice";/);
|
|
48
|
+
assert.match(out, /"getInvoice": \{ request: GetInvoiceRequest; response: GetInvoiceResponse \};/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("emitApiBindingsRuntime re-types the runtime defineOperation", () => {
|
|
52
|
+
const out = emitApiBindingsRuntime();
|
|
53
|
+
assert.match(out, /import \{ defineOperation as defineOperationRaw \} from "@nanobpm\/urban";/);
|
|
54
|
+
assert.match(out, /export function defineOperation<K extends ApiOperationId>/);
|
|
55
|
+
assert.match(out, /return defineOperationRaw\(id, handler\);/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("deriveApi emits both artifacts under nano-generated/", () => {
|
|
59
|
+
const arts = deriveApi(doc);
|
|
60
|
+
const paths = arts.map((a) => a.path).sort();
|
|
61
|
+
assert.deepEqual(paths, [`nano-generated/${API_BINDINGS_DTS}`, `nano-generated/${API_BINDINGS_TS}`]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("emitted object types mirror runtime additionalProperties semantics", () => {
|
|
65
|
+
// default (absent) → extras allowed at runtime, so the emitted type carries an index signature
|
|
66
|
+
assert.match(schemaToTs({ type: "object", properties: { a: { type: "string" } } }), /\[key: string\]: unknown/);
|
|
67
|
+
// additionalProperties: false → closed object, no index signature
|
|
68
|
+
assert.doesNotMatch(
|
|
69
|
+
schemaToTs({ type: "object", properties: { a: { type: "string" } }, additionalProperties: false }),
|
|
70
|
+
/\[key: string\]/,
|
|
71
|
+
);
|
|
72
|
+
// typed additionalProperties → typed index signature
|
|
73
|
+
assert.match(
|
|
74
|
+
schemaToTs({ type: "object", properties: {}, additionalProperties: { type: "number" } }),
|
|
75
|
+
/\[key: string\]: number/,
|
|
76
|
+
);
|
|
77
|
+
// a closed, empty object is Record<string, never>, not a loose Record<string, unknown>
|
|
78
|
+
assert.equal(schemaToTs({ type: "object", additionalProperties: false }), "Record<string, never>");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a typeless schema with properties is emitted as an object (matches the validator)", () => {
|
|
82
|
+
const out = schemaToTs({ properties: { a: { type: "string" } }, required: ["a"] });
|
|
83
|
+
assert.match(out, /"a": string/);
|
|
84
|
+
assert.match(out, /\[key: string\]: unknown/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("required query params aren't undefined-widened; optional ones use the ? modifier", () => {
|
|
88
|
+
const qdoc: OpenApiDoc = {
|
|
89
|
+
openapi: "3.0.0",
|
|
90
|
+
paths: {
|
|
91
|
+
"/search": {
|
|
92
|
+
get: {
|
|
93
|
+
operationId: "search",
|
|
94
|
+
parameters: [
|
|
95
|
+
{ name: "q", in: "query", required: true, schema: { type: "string" } },
|
|
96
|
+
{ name: "page", in: "query", schema: { type: "string" } },
|
|
97
|
+
],
|
|
98
|
+
responses: { "200": {} },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const out = emitApiBindings(qdoc);
|
|
104
|
+
assert.match(out, /"q": string \| string\[\]/); // required → present, not optional
|
|
105
|
+
assert.doesNotMatch(out, /"q"\?:/); // not optional
|
|
106
|
+
assert.doesNotMatch(out, /"q": string \| string\[\] \| undefined/); // not undefined-widened
|
|
107
|
+
assert.match(out, /"page"\?: string \| string\[\]/); // optional → ? modifier
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("request body optionality follows requestBody.required (runtime passes undefined when absent)", () => {
|
|
111
|
+
const mk = (required: boolean): OpenApiDoc => ({
|
|
112
|
+
openapi: "3.0.0",
|
|
113
|
+
paths: {
|
|
114
|
+
"/n": {
|
|
115
|
+
post: {
|
|
116
|
+
operationId: "op",
|
|
117
|
+
requestBody: {
|
|
118
|
+
required,
|
|
119
|
+
content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" } } } } },
|
|
120
|
+
},
|
|
121
|
+
responses: { "200": {} },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
assert.match(emitApiBindings(mk(false)), /body\?: \{/); // optional body → `?`
|
|
127
|
+
const req = emitApiBindings(mk(true));
|
|
128
|
+
assert.match(req, /\n {2}body: \{/); // required body → no `?`
|
|
129
|
+
assert.doesNotMatch(req, /body\?:/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("typed additionalProperties alongside declared props falls back to unknown (valid TS, no TS2411)", () => {
|
|
133
|
+
// A narrow index type (number) would make `{ "a": string; [key: string]: number }` invalid TS,
|
|
134
|
+
// so with declared properties the index signature widens to unknown; a pure map keeps its type.
|
|
135
|
+
const withProps = schemaToTs({
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: { a: { type: "string" } },
|
|
138
|
+
required: ["a"],
|
|
139
|
+
additionalProperties: { type: "number" },
|
|
140
|
+
});
|
|
141
|
+
assert.match(withProps, /"a": string/);
|
|
142
|
+
assert.match(withProps, /\[key: string\]: unknown/);
|
|
143
|
+
assert.doesNotMatch(withProps, /\[key: string\]: number/);
|
|
144
|
+
// No declared properties → precise map type is safe.
|
|
145
|
+
assert.match(
|
|
146
|
+
schemaToTs({ type: "object", additionalProperties: { type: "number" } }),
|
|
147
|
+
/\[key: string\]: number/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a required body with no JSON schema is typed `unknown`, not `undefined`", () => {
|
|
152
|
+
const mk = (required: boolean): OpenApiDoc => ({
|
|
153
|
+
openapi: "3.0.0",
|
|
154
|
+
paths: {
|
|
155
|
+
"/n": {
|
|
156
|
+
post: {
|
|
157
|
+
operationId: "op",
|
|
158
|
+
// requestBody with no parseable JSON schema (e.g. unsupported content)
|
|
159
|
+
requestBody: { required, content: { "text/plain": {} } },
|
|
160
|
+
responses: { "200": {} },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
assert.match(emitApiBindings(mk(true)), /\n {2}body: unknown;/); // required → body IS present
|
|
166
|
+
assert.match(emitApiBindings(mk(false)), /body\?: undefined;/); // optional/absent
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("request body types derive only from JSON media types (JSON-only surface)", () => {
|
|
170
|
+
const doc2: OpenApiDoc = {
|
|
171
|
+
openapi: "3.0.0",
|
|
172
|
+
paths: {
|
|
173
|
+
"/n": {
|
|
174
|
+
post: {
|
|
175
|
+
operationId: "op",
|
|
176
|
+
requestBody: {
|
|
177
|
+
required: true,
|
|
178
|
+
content: {
|
|
179
|
+
"application/xml": { schema: { type: "string" } }, // ignored
|
|
180
|
+
"application/vnd.acme+json": { schema: { type: "object", properties: { a: { type: "string" } } } },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
responses: { "200": {} },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const out = emitApiBindings(doc2);
|
|
189
|
+
assert.match(out, /body: \{ "a"\?: string/); // picked the +json schema, not the xml string
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("emitApiBindings fails closed on operationIds that collapse to the same TS type name", () => {
|
|
193
|
+
const clash: OpenApiDoc = {
|
|
194
|
+
openapi: "3.0.0",
|
|
195
|
+
paths: {
|
|
196
|
+
"/a": { get: { operationId: "get-invoice", responses: { "200": {} } } },
|
|
197
|
+
"/b": { get: { operationId: "get_invoice", responses: { "200": {} } } },
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
assert.throws(() => emitApiBindings(clash), /duplicate TypeScript type "GetInvoiceRequest"/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("emitApiBindings fails closed on component schema names that collapse to the same TS type name", () => {
|
|
204
|
+
const clash: OpenApiDoc = {
|
|
205
|
+
openapi: "3.0.0",
|
|
206
|
+
components: { schemas: { "Foo-Bar": { type: "object" }, "Foo_Bar": { type: "object" } } },
|
|
207
|
+
paths: {},
|
|
208
|
+
};
|
|
209
|
+
assert.throws(() => emitApiBindings(clash), /duplicate TypeScript type "FooBar"/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("apiDeriver is registered in the DERIVERS discovery registry", async () => {
|
|
213
|
+
const { DERIVERS, apiDeriver } = await import("../index.ts");
|
|
214
|
+
assert.ok(DERIVERS.includes(apiDeriver), "apiDeriver must be in DERIVERS");
|
|
215
|
+
assert.ok(DERIVERS.some((d) => d.id === "openapi->api-io"));
|
|
216
|
+
// Registry hygiene: no deriver is registered twice and every id is unique.
|
|
217
|
+
const ids = DERIVERS.map((d) => d.id);
|
|
218
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
219
|
+
});
|