@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.
Files changed (54) hide show
  1. package/dist/runtime/adapters/deno.js +5 -0
  2. package/dist/runtime/adapters/node.js +5 -0
  3. package/dist/runtime/core/execContext.d.ts +28 -0
  4. package/dist/runtime/core/execContext.js +39 -0
  5. package/dist/runtime/core/host.d.ts +41 -0
  6. package/dist/runtime/core/manifest.d.ts +1 -1
  7. package/dist/runtime/core/modules/connectors.js +3 -1
  8. package/dist/runtime/core/modules/datasource.d.ts +18 -1
  9. package/dist/runtime/core/modules/datasource.js +56 -2
  10. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  11. package/dist/runtime/core/modules/gateway.js +23 -6
  12. package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
  13. package/dist/runtime/core/modules/instance-tracking.js +141 -0
  14. package/dist/runtime/core/modules/pages.d.ts +6 -0
  15. package/dist/runtime/core/modules/pages.js +167 -13
  16. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  17. package/dist/runtime/core/modules/scheduler.js +21 -0
  18. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  19. package/dist/runtime/core/modules/triggers.js +1 -16
  20. package/dist/runtime/core/modules/workers.js +3 -2
  21. package/dist/runtime/core/runtime.d.ts +3 -0
  22. package/dist/runtime/core/runtime.js +12 -0
  23. package/dist/runtime/core/validate.js +38 -0
  24. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  25. package/dist/runtime/engine/nanosdk.js +49 -0
  26. package/package.json +2 -2
  27. package/src/runtime/adapters/deno.ts +6 -0
  28. package/src/runtime/adapters/node.ts +6 -0
  29. package/src/runtime/core/execContext.ts +56 -0
  30. package/src/runtime/core/host.ts +44 -0
  31. package/src/runtime/core/manifest.ts +1 -0
  32. package/src/runtime/core/modules/actions.test.ts +1 -0
  33. package/src/runtime/core/modules/connectors.test.ts +1 -0
  34. package/src/runtime/core/modules/connectors.ts +7 -1
  35. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  36. package/src/runtime/core/modules/datasource.ts +72 -2
  37. package/src/runtime/core/modules/gateway.ts +28 -6
  38. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  39. package/src/runtime/core/modules/instance-tracking.ts +158 -0
  40. package/src/runtime/core/modules/pages.test.ts +77 -2
  41. package/src/runtime/core/modules/pages.ts +171 -13
  42. package/src/runtime/core/modules/provenance.test.ts +181 -0
  43. package/src/runtime/core/modules/scheduler.ts +31 -0
  44. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  45. package/src/runtime/core/modules/triggers.test.ts +1 -0
  46. package/src/runtime/core/modules/triggers.ts +3 -25
  47. package/src/runtime/core/modules/workers.test.ts +1 -0
  48. package/src/runtime/core/modules/workers.ts +11 -2
  49. package/src/runtime/core/runtime.test.ts +3 -0
  50. package/src/runtime/core/runtime.ts +16 -0
  51. package/src/runtime/core/validate.test.ts +96 -0
  52. package/src/runtime/core/validate.ts +39 -0
  53. package/src/runtime/engine/nanosdk.test.ts +73 -0
  54. 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,