@nanobpm/urban 0.90.2 → 0.92.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.
@@ -640,9 +640,10 @@ export class SdkEngineClient {
640
640
  }
641
641
  async searchElementInstanceWaitStates(filter) {
642
642
  // The wait-states search surfaces the parks the deployed engine's read model serves.
643
- // A `waitStateType` selector outside the deployed floor (`JOB | MESSAGE`) is rejected by
644
- // the gateway with HTTP 422; fail fast client-side with a clear error instead, so the
645
- // divergence is caught offline and identically to the WASM adapter (No Drift Surfaces).
643
+ // A `waitStateType` selector outside the deployed floor (`JOB | MESSAGE | USER_TASK`) is
644
+ // rejected by the gateway with HTTP 422; fail fast client-side with a clear error instead,
645
+ // so the divergence is caught offline and identically to the WASM adapter (No Drift
646
+ // Surfaces).
646
647
  assertDeployedWaitStateType(filter?.waitStateType);
647
648
  // Same zero-wait read + per-row mapping-gate shape as `searchElementInstances`; the SDK
648
649
  // nests park-specific fields under `details`, which `mapElementInstanceWaitStateRow`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.90.2",
3
+ "version": "0.92.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1955,9 +1955,17 @@ function buildEngineForm(cfg, row, onSuccess) {
1955
1955
  const formKey = row[cfg.formKeyField || "form_key"];
1956
1956
  const box = el("div", { class: "pc-subform" });
1957
1957
  const msg = el("p", { class: "njf-msg", role: "status", "aria-live": "polite" });
1958
+ // Optional page-author knob: route completion through the app's own canonical
1959
+ // door instead of the runtime's generic seam. Defaults to /app/actions/complete
1960
+ // when unset (existing pages unchanged). Same trust class as detail.form's
1961
+ // action.path; rebased through apiUrl() by getJSON like every other page fetch.
1962
+ const completePath =
1963
+ typeof cfg.completePath === "string" && cfg.completePath.trim() !== ""
1964
+ ? cfg.completePath.trim()
1965
+ : "/app/actions/complete";
1958
1966
  /** @param {Record<string, any>} variables */
1959
1967
  const complete = (variables) =>
1960
- getJSON("/app/actions/complete", {
1968
+ getJSON(completePath, {
1961
1969
  method: "POST",
1962
1970
  headers: { "content-type": "application/json" },
1963
1971
  body: JSON.stringify({ userTaskKey, variables }),
@@ -84,6 +84,7 @@ test("buildEngineForm resolves the row's formKey and completes with the entered
84
84
  assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
85
85
  input!.value = "done";
86
86
  const form = created.find((n) => n.tagName === "FORM");
87
+ assert.ok(form, "the deployed schema rendered a form element to submit");
87
88
  await form!.fire("submit");
88
89
  await new Promise((r) => setTimeout(r, 0));
89
90
  const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
@@ -116,6 +117,7 @@ test("buildEngineForm surfaces a completion failure in the rendered-form path",
116
117
  assert.ok(msg, "a failed completion is surfaced to the operator (not silent)");
117
118
  assert.notEqual(msg!.textContent, "", "the error message element has text");
118
119
  const submit = created.find((n) => n.tagName === "BUTTON" && n.type === "submit");
120
+ assert.ok(submit, "the completion path rendered a submit button");
119
121
  assert.equal(submit!.disabled, false, "the submit button is re-enabled so the operator can retry");
120
122
  });
121
123
 
@@ -156,3 +158,101 @@ test("buildEngineForm degrades to bare completion when the formKey resolves to n
156
158
  const button = created.find((n) => n.tagName === "BUTTON" && n.textContent === "Complete");
157
159
  assert.ok(button, "a 204 (no form) falls back to the bare completion button");
158
160
  });
161
+
162
+ test("buildEngineForm routes the rendered-form completion through a configured completePath", async (t) => {
163
+ const created: FakeElement[] = [];
164
+ const calls: FetchCall[] = [];
165
+ t.after(installFakeDom(created));
166
+ t.after(
167
+ installFakeFetch((url) => {
168
+ if (url.includes("/app/actions/form")) {
169
+ return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
170
+ }
171
+ return { status: 200, json: { ok: true } };
172
+ }, calls),
173
+ );
174
+ const box = buildEngineForm(
175
+ { ...CFG, completePath: "/app/api/actions/complete-user-task" },
176
+ { form_key: "form-c", user_task_key: "ut-c" },
177
+ () => {},
178
+ );
179
+ assert.ok(box);
180
+ await new Promise((r) => setTimeout(r, 0));
181
+ const input = created.find((n) => n.tagName === "INPUT");
182
+ assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
183
+ input!.value = "done";
184
+ const form = created.find((n) => n.tagName === "FORM");
185
+ assert.ok(form, "the deployed schema rendered a form element to submit");
186
+ await form!.fire("submit");
187
+ await new Promise((r) => setTimeout(r, 0));
188
+ const completeReq = calls.find((c) => c.url.includes("/app/api/actions/complete-user-task"));
189
+ assert.ok(completeReq, "completion POSTs to the configured completePath, not the generic seam");
190
+ assert.equal(completeReq!.method, "POST");
191
+ assert.deepEqual(completeReq!.body, { userTaskKey: "ut-c", variables: { note: "done" } });
192
+ assert.equal(
193
+ calls.some((c) => c.url.includes("/app/actions/complete")),
194
+ false,
195
+ "the generic seam is not called when completePath is set",
196
+ );
197
+ });
198
+
199
+ test("buildEngineForm routes the bare completion through a configured completePath", async (t) => {
200
+ const created: FakeElement[] = [];
201
+ const calls: FetchCall[] = [];
202
+ t.after(installFakeDom(created));
203
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
204
+ const box = buildEngineForm(
205
+ { ...CFG, completePath: "/app/custom-complete" },
206
+ { user_task_key: "ut-b" },
207
+ () => {},
208
+ );
209
+ assert.ok(box);
210
+ const button = created.find((n) => n.tagName === "BUTTON");
211
+ assert.ok(button, "the bare completion path rendered a Complete button");
212
+ await button!.fire("click");
213
+ await new Promise((r) => setTimeout(r, 0));
214
+ const completeReq = calls.find((c) => c.url.includes("/app/custom-complete"));
215
+ assert.ok(completeReq, "the bare button completes via the configured completePath");
216
+ assert.equal(completeReq!.body.userTaskKey, "ut-b");
217
+ assert.deepEqual(completeReq!.body.variables, {}, "bare completion sends empty variables");
218
+ assert.equal(
219
+ calls.some((c) => c.url.includes("/app/actions/complete")),
220
+ false,
221
+ "the generic seam is not called when completePath is set",
222
+ );
223
+ });
224
+
225
+ test("buildEngineForm falls back to the generic seam when completePath is empty/absent", async (t) => {
226
+ const created: FakeElement[] = [];
227
+ const calls: FetchCall[] = [];
228
+ t.after(installFakeDom(created));
229
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
230
+ // An empty-string completePath is treated as absent (default seam preserved).
231
+ const box = buildEngineForm({ ...CFG, completePath: "" }, { user_task_key: "ut-d" }, () => {});
232
+ assert.ok(box);
233
+ const button = created.find((n) => n.tagName === "BUTTON");
234
+ assert.ok(button, "the bare completion path rendered a Complete button");
235
+ await button!.fire("click");
236
+ await new Promise((r) => setTimeout(r, 0));
237
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
238
+ assert.ok(completeReq, "an empty completePath still targets the default /app/actions/complete seam");
239
+ assert.equal(completeReq!.body.userTaskKey, "ut-d");
240
+ });
241
+
242
+ test("buildEngineForm treats a whitespace-only completePath as absent", async (t) => {
243
+ const created: FakeElement[] = [];
244
+ const calls: FetchCall[] = [];
245
+ t.after(installFakeDom(created));
246
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
247
+ // A whitespace-only completePath is normalized away so it never becomes an
248
+ // unintended relative fetch target — the default seam is preserved.
249
+ const box = buildEngineForm({ ...CFG, completePath: " " }, { user_task_key: "ut-e" }, () => {});
250
+ assert.ok(box);
251
+ const button = created.find((n) => n.tagName === "BUTTON");
252
+ assert.ok(button, "the bare completion path rendered a Complete button");
253
+ await button!.fire("click");
254
+ await new Promise((r) => setTimeout(r, 0));
255
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
256
+ assert.ok(completeReq, "a whitespace-only completePath still targets the default /app/actions/complete seam");
257
+ assert.equal(completeReq!.body.userTaskKey, "ut-e");
258
+ });
@@ -350,7 +350,9 @@ export interface ElementInstanceSummary {
350
350
  * {@link ElementInstanceWaitStateFilter} whose `waitStateType` falls outside that floor is
351
351
  * rejected (the gateway answers HTTP 422); {@link assertDeployedWaitStateType} enforces the
352
352
  * same floor client-side so the divergence surfaces as a clear error offline, in tests, and
353
- * against a live engine alike. Ref: Magikcraft/nano-bpm#1042.
353
+ * against a live engine alike. The floor now covers `JOB | MESSAGE | USER_TASK`
354
+ * (Magikcraft/nano-bpm#1042 shipped `USER_TASK` parks); `TIMER`/`SIGNAL`/`CONDITION` remain
355
+ * the follow-on. Ref: Magikcraft/nano-bpm#1042.
354
356
  */
355
357
  export type WaitStateType =
356
358
  | "JOB"
@@ -363,10 +365,12 @@ export type WaitStateType =
363
365
  /**
364
366
  * The `waitStateType` values the *currently deployed* nanobpmn gateway's wait-state read
365
367
  * model actually implements — the **deployed floor**, a strict subset of the canonical
366
- * {@link WaitStateType} union. The gateway's read model is a snapshot of the Zeebe contract
367
- * taken 2026-06-09 (the same day Zeebe extended it with `USER_TASK`, then `TIMER`/`SIGNAL`/
368
- * `CONDITION`), so it recognizes only `JOB | MESSAGE` and rejects any other filter value
369
- * with HTTP 422.
368
+ * {@link WaitStateType} union. The gateway's read model started as a snapshot of the Zeebe
369
+ * contract taken 2026-06-09 that recognized only `JOB | MESSAGE`; Magikcraft/nano-bpm#1042
370
+ * has since shipped `USER_TASK` parks (added on user-task CREATED, removed on
371
+ * COMPLETED/CANCELED, with the park identity under `details.taskKey`), moving the floor up to
372
+ * `JOB | MESSAGE | USER_TASK`. `TIMER`/`SIGNAL`/`CONDITION` are still rejected with HTTP 422
373
+ * (the 8.10 follow-on tracked in Magikcraft/nano-bpm#1042).
370
374
  *
371
375
  * This constant is the **single source of truth** for that floor: the SDK-backed and
372
376
  * WASM-backed {@link EngineClient} adapters both gate through {@link assertDeployedWaitStateType},
@@ -375,7 +379,7 @@ export type WaitStateType =
375
379
  * backed by the real gateway) only once the engine actually serves the richer type — the
376
380
  * tracking gap is Magikcraft/nano-bpm#1042.
377
381
  */
378
- export const DEPLOYED_WAIT_STATE_TYPES: readonly WaitStateType[] = ["JOB", "MESSAGE"];
382
+ export const DEPLOYED_WAIT_STATE_TYPES: readonly WaitStateType[] = ["JOB", "MESSAGE", "USER_TASK"];
379
383
 
380
384
  /**
381
385
  * Thrown when a wait-state search is asked to filter on a {@link WaitStateType} the deployed
@@ -701,14 +705,14 @@ export interface EngineClient {
701
705
  * (see {@link ElementInstanceWaitState}), so a consumer can read the job/message parks a
702
706
  * {@link searchUserTasks} cannot surface.
703
707
  *
704
- * **Deployed floor.** The currently deployed nanobpmn gateway implements only
705
- * `JOB | MESSAGE` ({@link DEPLOYED_WAIT_STATE_TYPES}) — a `USER_TASK` park is read via
706
- * {@link searchUserTasks}, not here. A `waitStateType` filter outside the floor is rejected
707
- * with {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422); an
708
- * unfiltered search returns only in-floor parks. The `waitStateType` union is
709
- * forward-looking (richer engines may serve more), but this adapter contract pins the
710
- * floor so a query cannot pass in emulation while a real engine rejects it. Ref:
711
- * Magikcraft/nano-bpm#1042.
708
+ * **Deployed floor.** The currently deployed nanobpmn gateway implements
709
+ * `JOB | MESSAGE | USER_TASK` ({@link DEPLOYED_WAIT_STATE_TYPES}); a `USER_TASK` park can be
710
+ * read here (or through {@link searchUserTasks}). A `waitStateType` filter outside the floor
711
+ * (`TIMER`/`SIGNAL`/`CONDITION`) is rejected with {@link UnsupportedWaitStateTypeError} (the
712
+ * live gateway answers HTTP 422); an unfiltered search returns only in-floor parks. The
713
+ * `waitStateType` union is forward-looking (richer engines may serve more), but this adapter
714
+ * contract pins the floor so a query cannot pass in emulation while a real engine rejects it.
715
+ * Ref: Magikcraft/nano-bpm#1042.
712
716
  */
713
717
  searchElementInstanceWaitStates(
714
718
  filter?: ElementInstanceWaitStateFilter,