@nanobpm/urban 0.49.1 → 0.50.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.
@@ -0,0 +1,58 @@
1
+ import { presentFormIdentifier } from "./host.ts";
2
+ import type { FormSchema } from "./host.ts";
3
+ export { presentFormIdentifier };
4
+ /**
5
+ * Resolve which identifier addresses a form for {@link EngineClient.getForm}, applying the
6
+ * contract's shared presence + preference rule: prefer a present `formKey`, else fall back
7
+ * to a present `formId`; an empty/whitespace-only value is *absent* (via
8
+ * {@link presentFormIdentifier}), so a blank `?formKey=` falls through to a valid `formId`
9
+ * instead of short-circuiting to null. Returns `null` when neither identifier is present.
10
+ *
11
+ * `kind` distinguishes a resolved deploy key (`"key"`) from an authored form id (`"id"`),
12
+ * which a key-addressed store (the WASM adapter) must itself resolve to a deploy key; a
13
+ * key/id-agnostic backend (the REST gateway) can ignore `kind` and address by `value`.
14
+ */
15
+ export declare function resolveFormIdentifier(input: {
16
+ formKey?: string;
17
+ formId?: string;
18
+ }): {
19
+ kind: "key" | "id";
20
+ value: string;
21
+ } | null;
22
+ /**
23
+ * Parse a deployed form-js schema into the object the surface renders. The engine
24
+ * serializes it as a JSON string, so parse it; tolerate an already-parsed object (an
25
+ * in-memory/embedded engine). Returns `null` for anything that is neither a JSON object
26
+ * string nor an object (which the caller treats as "no such form").
27
+ */
28
+ export declare function parseFormSchema(raw: unknown): Record<string, unknown> | null;
29
+ /**
30
+ * Assemble the {@link FormSchema} result with the contract's presence guards: a blank or
31
+ * whitespace-only `formKey`/`formId`, or a non-numeric `version`, is omitted rather than
32
+ * surfaced as an empty/garbage value. Identifiers are trimmed (the shared presence rule), so
33
+ * a padded `formKey` never leaks whitespace; `formKey` is stringified (the REST body may carry
34
+ * it as a number).
35
+ */
36
+ export declare function buildFormSchema(parts: {
37
+ schema: Record<string, unknown>;
38
+ formKey?: unknown;
39
+ formId?: unknown;
40
+ version?: unknown;
41
+ }): FormSchema;
42
+ /**
43
+ * Select a user task's form linkage from a raw engine record for
44
+ * {@link EngineClient.searchUserTasks}, applying the contract's non-empty rule (a blank
45
+ * `formKey`/`externalFormReference` is absent). The engine resolves a
46
+ * `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form's key at task
47
+ * creation, so a resolved `formKey` (not a form id) is the linkage a task reports.
48
+ *
49
+ * A key-addressed engine that does *not* itself resolve that linkage (the WASM adapter)
50
+ * can pass `resolveFormKeyByFormId` to map an authored `formId` on the task to the deploy
51
+ * key; a backend that already resolved it (the REST gateway) omits the resolver, and the
52
+ * authored-id path is simply not taken. A blank or whitespace-only `formKey`/`formId`/
53
+ * `externalFormReference` is treated as absent (the shared trimming presence rule).
54
+ */
55
+ export declare function pickFormLinkage(raw: Record<string, unknown>, resolveFormKeyByFormId?: (formId: string) => string | undefined): {
56
+ formKey?: string;
57
+ externalFormReference?: string;
58
+ };
@@ -0,0 +1,108 @@
1
+ // The shared, transport-agnostic normalization glue for the form + user-task parts of
2
+ // the {@link EngineClient} contract. Both engine adapters — the live SDK/REST adapter
3
+ // (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process WASM test adapter
4
+ // (`@nanobpm/urban-testkit`, `WasmEngineClient`) — reproduce the same API-layer
5
+ // conveniences (form storage + `formId`→`formKey` resolution, form-schema parsing,
6
+ // user-task form linkage) on top of their own transport. Historically each adapter
7
+ // hand-mirrored these rules (with literal `// Mirror … exactly` comments), and that
8
+ // copy-paste drifted — e.g. a local `presentFormIdentifier` copy that trimmed for the
9
+ // emptiness check but returned the *untrimmed* value. Centralizing the rules here so
10
+ // both adapters call the *same* code removes that drift class (issue #252).
11
+ import { presentFormIdentifier } from "./host.js";
12
+ import { isRecord } from "./guards.js";
13
+ export { presentFormIdentifier };
14
+ /**
15
+ * Presence-check a possibly-numeric identifier under the shared trim rule. The REST body may
16
+ * carry a `formKey` as a number, so coerce it to a string; an empty or whitespace-only string
17
+ * is *absent* (via {@link presentFormIdentifier}, which also trims). Any other type is absent.
18
+ * This is the same trimming presence rule {@link resolveFormIdentifier} applies, so a blank
19
+ * key can never be surfaced as an empty/whitespace value — the drift-bug class #252 targets.
20
+ */
21
+ function presentKey(v) {
22
+ if (typeof v === "number")
23
+ return presentFormIdentifier(String(v));
24
+ if (typeof v === "string")
25
+ return presentFormIdentifier(v);
26
+ return undefined;
27
+ }
28
+ /**
29
+ * Resolve which identifier addresses a form for {@link EngineClient.getForm}, applying the
30
+ * contract's shared presence + preference rule: prefer a present `formKey`, else fall back
31
+ * to a present `formId`; an empty/whitespace-only value is *absent* (via
32
+ * {@link presentFormIdentifier}), so a blank `?formKey=` falls through to a valid `formId`
33
+ * instead of short-circuiting to null. Returns `null` when neither identifier is present.
34
+ *
35
+ * `kind` distinguishes a resolved deploy key (`"key"`) from an authored form id (`"id"`),
36
+ * which a key-addressed store (the WASM adapter) must itself resolve to a deploy key; a
37
+ * key/id-agnostic backend (the REST gateway) can ignore `kind` and address by `value`.
38
+ */
39
+ export function resolveFormIdentifier(input) {
40
+ const key = presentFormIdentifier(input.formKey);
41
+ if (key != null)
42
+ return { kind: "key", value: key };
43
+ const id = presentFormIdentifier(input.formId);
44
+ if (id != null)
45
+ return { kind: "id", value: id };
46
+ return null;
47
+ }
48
+ /**
49
+ * Parse a deployed form-js schema into the object the surface renders. The engine
50
+ * serializes it as a JSON string, so parse it; tolerate an already-parsed object (an
51
+ * in-memory/embedded engine). Returns `null` for anything that is neither a JSON object
52
+ * string nor an object (which the caller treats as "no such form").
53
+ */
54
+ export function parseFormSchema(raw) {
55
+ if (isRecord(raw))
56
+ return raw;
57
+ if (typeof raw !== "string")
58
+ return null;
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ return isRecord(parsed) ? parsed : null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Assemble the {@link FormSchema} result with the contract's presence guards: a blank or
69
+ * whitespace-only `formKey`/`formId`, or a non-numeric `version`, is omitted rather than
70
+ * surfaced as an empty/garbage value. Identifiers are trimmed (the shared presence rule), so
71
+ * a padded `formKey` never leaks whitespace; `formKey` is stringified (the REST body may carry
72
+ * it as a number).
73
+ */
74
+ export function buildFormSchema(parts) {
75
+ const formKey = presentKey(parts.formKey);
76
+ const formId = typeof parts.formId === "string" ? presentFormIdentifier(parts.formId) : undefined;
77
+ return {
78
+ schema: parts.schema,
79
+ ...(formKey != null ? { formKey } : {}),
80
+ ...(formId != null ? { formId } : {}),
81
+ ...(typeof parts.version === "number" ? { version: parts.version } : {}),
82
+ };
83
+ }
84
+ /**
85
+ * Select a user task's form linkage from a raw engine record for
86
+ * {@link EngineClient.searchUserTasks}, applying the contract's non-empty rule (a blank
87
+ * `formKey`/`externalFormReference` is absent). The engine resolves a
88
+ * `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form's key at task
89
+ * creation, so a resolved `formKey` (not a form id) is the linkage a task reports.
90
+ *
91
+ * A key-addressed engine that does *not* itself resolve that linkage (the WASM adapter)
92
+ * can pass `resolveFormKeyByFormId` to map an authored `formId` on the task to the deploy
93
+ * key; a backend that already resolved it (the REST gateway) omits the resolver, and the
94
+ * authored-id path is simply not taken. A blank or whitespace-only `formKey`/`formId`/
95
+ * `externalFormReference` is treated as absent (the shared trimming presence rule).
96
+ */
97
+ export function pickFormLinkage(raw, resolveFormKeyByFormId) {
98
+ const directKey = presentKey(raw.formKey);
99
+ const authoredId = typeof raw.formId === "string" ? presentFormIdentifier(raw.formId) : undefined;
100
+ const formKey = directKey ?? (authoredId && resolveFormKeyByFormId ? resolveFormKeyByFormId(authoredId) : undefined);
101
+ const externalFormReference = typeof raw.externalFormReference === "string"
102
+ ? presentFormIdentifier(raw.externalFormReference)
103
+ : undefined;
104
+ return {
105
+ ...(formKey ? { formKey } : {}),
106
+ ...(externalFormReference ? { externalFormReference } : {}),
107
+ };
108
+ }
@@ -18,7 +18,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
18
18
  }
19
19
  return path;
20
20
  };
21
- import { isBpmnError, presentFormIdentifier } from "../core/host.js";
21
+ import { isBpmnError } from "../core/host.js";
22
+ import { buildFormSchema, parseFormSchema, pickFormLinkage, resolveFormIdentifier, } from "../core/form-contract.js";
22
23
  import { isRecord } from "../core/guards.js";
23
24
  /** Coerce an engine response's process-instance key to a non-empty string, or
24
25
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -101,13 +102,7 @@ export class SdkEngineClient {
101
102
  async searchUserTasks(filter) {
102
103
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
103
104
  // the search reflects what is currently visible without blocking.
104
- const body = await this.client.searchUserTasks(
105
- // This method is contracted to return *open* tasks; constrain the read to
106
- // CREATED so answered/canceled tasks never surface as answerable (e.g. a
107
- // re-escalated instance whose prior escalation task is already COMPLETED).
108
- // Spread caller filters first and pin `state` last so a caller can never
109
- // override the open-only (CREATED) constraint this method contracts to.
110
- { filter: { ...(filter ?? {}), state: "CREATED" } }, { consistency: { waitUpToMs: 0 } });
105
+ const body = await this.client.searchUserTasks({ filter: { ...(filter ?? {}) } }, { consistency: { waitUpToMs: 0 } });
111
106
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
112
107
  return items.flatMap((it) => {
113
108
  const userTaskKey = it.userTaskKey ?? it.key;
@@ -116,17 +111,13 @@ export class SdkEngineClient {
116
111
  return [];
117
112
  }
118
113
  // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
119
- // task creation, so `formKey` (not a form id) is the linkage it reports on the task.
120
- const formKey = it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
121
- const externalFormReference = typeof it.externalFormReference === "string" && it.externalFormReference !== ""
122
- ? it.externalFormReference
123
- : undefined;
114
+ // task creation, so `formKey` (not a form id) is the linkage it reports on the task;
115
+ // no `resolveFormKeyByFormId` resolver is passed because the gateway already resolved it.
124
116
  return [{
125
117
  userTaskKey: String(userTaskKey),
126
118
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
127
119
  variables: isRecord(it.variables) ? it.variables : undefined,
128
- ...(formKey ? { formKey } : {}),
129
- ...(externalFormReference ? { externalFormReference } : {}),
120
+ ...pickFormLinkage(it),
130
121
  }];
131
122
  });
132
123
  }
@@ -138,13 +129,15 @@ export class SdkEngineClient {
138
129
  // form or 404s (→ null) when there is no such form.
139
130
  //
140
131
  // This is the single gate that resolves which identifier addresses the form: an
141
- // empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
142
- // shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
132
+ // empty/whitespace identifier is treated as *absent* (via the shared presence rule in
133
+ // `resolveFormIdentifier`) so a blank `formKey` (e.g. a `?formKey=` query param) falls
143
134
  // through to a valid `formId` instead of being taken as a present-but-unresolvable key
144
- // that short-circuits to null.
145
- const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
146
- if (key == null)
135
+ // that short-circuits to null. The REST gateway addresses a form by `value` regardless
136
+ // of whether it is a deploy key or an authored id, so `kind` is not consulted here.
137
+ const resolved = resolveFormIdentifier(input);
138
+ if (resolved == null)
147
139
  return null;
140
+ const key = resolved.value;
148
141
  let body;
149
142
  try {
150
143
  body = await this.client.getFormByKey({ formKey: key }, { consistency: { waitUpToMs: 0 } });
@@ -160,31 +153,21 @@ export class SdkEngineClient {
160
153
  }
161
154
  if (!isRecord(body))
162
155
  return null;
163
- const rawSchema = body.schema;
164
156
  // The engine serializes the form-js schema as a JSON string; parse it to the object
165
157
  // the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
166
- let schema;
167
- if (typeof rawSchema === "string") {
168
- try {
169
- const parsed = JSON.parse(rawSchema);
170
- if (isRecord(parsed))
171
- schema = parsed;
172
- }
173
- catch {
158
+ const schema = parseFormSchema(body.schema);
159
+ if (!schema) {
160
+ if (typeof body.schema === "string") {
174
161
  this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
175
162
  }
176
- }
177
- else if (isRecord(rawSchema)) {
178
- schema = rawSchema;
179
- }
180
- if (!schema)
181
163
  return null;
182
- return {
164
+ }
165
+ return buildFormSchema({
183
166
  schema,
184
- ...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
185
- ...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
186
- ...(typeof body.version === "number" ? { version: body.version } : {}),
187
- };
167
+ formKey: body.formKey,
168
+ formId: body.formId,
169
+ version: body.version,
170
+ });
188
171
  }
189
172
  async completeUserTask(userTaskKey, variables) {
190
173
  await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
@@ -4,7 +4,8 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
4
4
  export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
5
5
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
- export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
7
+ export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
8
+ export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
8
9
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
10
  export { createLogger } from "./core/logger.ts";
10
11
  export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
@@ -5,6 +5,10 @@ export { createUrbanApp } from "./core/runtime.js";
5
5
  export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.js";
6
6
  // Validation
7
7
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
8
+ // Shared form/user-task contract normalization (issue #252): the single source of truth
9
+ // both engine adapters (SDK/REST + WASM test double) call, so their form-identifier,
10
+ // form-schema, and user-task form-linkage handling cannot drift.
11
+ export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.js";
8
12
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
9
13
  // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
10
14
  export { createLogger } from "./core/logger.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.49.1",
3
+ "version": "0.50.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",
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ buildFormSchema,
5
+ parseFormSchema,
6
+ pickFormLinkage,
7
+ resolveFormIdentifier,
8
+ } from "./form-contract.ts";
9
+
10
+ test("resolveFormIdentifier prefers a present formKey over a formId", () => {
11
+ assert.deepEqual(resolveFormIdentifier({ formKey: "42", formId: "myForm" }), {
12
+ kind: "key",
13
+ value: "42",
14
+ });
15
+ });
16
+
17
+ test("resolveFormIdentifier falls back to formId when formKey is absent/blank/whitespace", () => {
18
+ assert.deepEqual(resolveFormIdentifier({ formId: "myForm" }), { kind: "id", value: "myForm" });
19
+ for (const formKey of ["", " "]) {
20
+ assert.deepEqual(
21
+ resolveFormIdentifier({ formKey, formId: "myForm" }),
22
+ { kind: "id", value: "myForm" },
23
+ `formKey=${JSON.stringify(formKey)} must fall through to formId`,
24
+ );
25
+ }
26
+ });
27
+
28
+ test("resolveFormIdentifier trims the resolved value and returns null when neither is present", () => {
29
+ assert.deepEqual(resolveFormIdentifier({ formKey: " 42 " }), { kind: "key", value: "42" });
30
+ assert.equal(resolveFormIdentifier({}), null);
31
+ assert.equal(resolveFormIdentifier({ formKey: " ", formId: "" }), null);
32
+ });
33
+
34
+ test("parseFormSchema parses a JSON-object string, passes an object through, and rejects the rest", () => {
35
+ assert.deepEqual(parseFormSchema('{"a":1}'), { a: 1 });
36
+ const obj = { a: 1 };
37
+ assert.equal(parseFormSchema(obj), obj);
38
+ assert.equal(parseFormSchema("not json"), null);
39
+ assert.equal(parseFormSchema("[1,2]"), null, "a JSON array is not a form schema object");
40
+ assert.equal(parseFormSchema(42), null);
41
+ assert.equal(parseFormSchema(null), null);
42
+ });
43
+
44
+ test("buildFormSchema applies presence guards and stringifies a numeric formKey", () => {
45
+ const schema = { components: [] };
46
+ assert.deepEqual(buildFormSchema({ schema, formKey: 42, formId: "myForm", version: 3 }), {
47
+ schema,
48
+ formKey: "42",
49
+ formId: "myForm",
50
+ version: 3,
51
+ });
52
+ // Blank/garbage parts are omitted rather than surfaced as empty values.
53
+ assert.deepEqual(buildFormSchema({ schema, formKey: "", formId: "", version: "3" }), { schema });
54
+ assert.deepEqual(buildFormSchema({ schema }), { schema });
55
+ });
56
+
57
+ test("buildFormSchema trims padded identifiers and drops whitespace-only ones (the #252 drift bug)", () => {
58
+ const schema = { components: [] };
59
+ // Whitespace-only key/id are absent, not surfaced as " ".
60
+ assert.deepEqual(buildFormSchema({ schema, formKey: " ", formId: " " }), { schema });
61
+ // A padded key/id is trimmed to its space-free value, not surfaced with the padding.
62
+ assert.deepEqual(buildFormSchema({ schema, formKey: " 42 ", formId: " myForm " }), {
63
+ schema,
64
+ formKey: "42",
65
+ formId: "myForm",
66
+ });
67
+ });
68
+
69
+ test("pickFormLinkage prefers a direct formKey and carries an externalFormReference", () => {
70
+ assert.deepEqual(
71
+ pickFormLinkage({ formKey: 42, externalFormReference: "https://x/form" }),
72
+ { formKey: "42", externalFormReference: "https://x/form" },
73
+ );
74
+ // Blank linkage fields are treated as absent.
75
+ assert.deepEqual(pickFormLinkage({ formKey: "", externalFormReference: "" }), {});
76
+ });
77
+
78
+ test("pickFormLinkage trims padded linkage fields and drops whitespace-only ones", () => {
79
+ // Whitespace-only key/id/ref are absent, not surfaced with whitespace.
80
+ assert.deepEqual(
81
+ pickFormLinkage({ formKey: " ", externalFormReference: " " }),
82
+ {},
83
+ );
84
+ // A padded key/ref is trimmed; a padded authored id is trimmed before the resolver sees it.
85
+ assert.deepEqual(
86
+ pickFormLinkage({ formKey: " 7 ", externalFormReference: " https://x/form " }),
87
+ { formKey: "7", externalFormReference: "https://x/form" },
88
+ );
89
+ assert.deepEqual(
90
+ pickFormLinkage({ formId: " myForm " }, (id) => (id === "myForm" ? "form-7" : undefined)),
91
+ { formKey: "form-7" },
92
+ "the authored formId is trimmed before the key resolver is called",
93
+ );
94
+ });
95
+
96
+ test("pickFormLinkage resolves an authored formId to a deploy key only via the callback", () => {
97
+ const raw = { formId: "myForm" };
98
+ // No resolver (the gateway path): an authored id is not turned into a key.
99
+ assert.deepEqual(pickFormLinkage(raw), {});
100
+ // With a resolver (the WASM/key-addressed path): the id maps to the latest deploy key.
101
+ assert.deepEqual(
102
+ pickFormLinkage(raw, (id) => (id === "myForm" ? "form-7" : undefined)),
103
+ { formKey: "form-7" },
104
+ );
105
+ });
@@ -0,0 +1,121 @@
1
+ // The shared, transport-agnostic normalization glue for the form + user-task parts of
2
+ // the {@link EngineClient} contract. Both engine adapters — the live SDK/REST adapter
3
+ // (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process WASM test adapter
4
+ // (`@nanobpm/urban-testkit`, `WasmEngineClient`) — reproduce the same API-layer
5
+ // conveniences (form storage + `formId`→`formKey` resolution, form-schema parsing,
6
+ // user-task form linkage) on top of their own transport. Historically each adapter
7
+ // hand-mirrored these rules (with literal `// Mirror … exactly` comments), and that
8
+ // copy-paste drifted — e.g. a local `presentFormIdentifier` copy that trimmed for the
9
+ // emptiness check but returned the *untrimmed* value. Centralizing the rules here so
10
+ // both adapters call the *same* code removes that drift class (issue #252).
11
+
12
+ import { presentFormIdentifier } from "./host.ts";
13
+ import type { FormSchema } from "./host.ts";
14
+ import { isRecord } from "./guards.ts";
15
+
16
+ export { presentFormIdentifier };
17
+
18
+ /**
19
+ * Presence-check a possibly-numeric identifier under the shared trim rule. The REST body may
20
+ * carry a `formKey` as a number, so coerce it to a string; an empty or whitespace-only string
21
+ * is *absent* (via {@link presentFormIdentifier}, which also trims). Any other type is absent.
22
+ * This is the same trimming presence rule {@link resolveFormIdentifier} applies, so a blank
23
+ * key can never be surfaced as an empty/whitespace value — the drift-bug class #252 targets.
24
+ */
25
+ function presentKey(v: unknown): string | undefined {
26
+ if (typeof v === "number") return presentFormIdentifier(String(v));
27
+ if (typeof v === "string") return presentFormIdentifier(v);
28
+ return undefined;
29
+ }
30
+
31
+ /**
32
+ * Resolve which identifier addresses a form for {@link EngineClient.getForm}, applying the
33
+ * contract's shared presence + preference rule: prefer a present `formKey`, else fall back
34
+ * to a present `formId`; an empty/whitespace-only value is *absent* (via
35
+ * {@link presentFormIdentifier}), so a blank `?formKey=` falls through to a valid `formId`
36
+ * instead of short-circuiting to null. Returns `null` when neither identifier is present.
37
+ *
38
+ * `kind` distinguishes a resolved deploy key (`"key"`) from an authored form id (`"id"`),
39
+ * which a key-addressed store (the WASM adapter) must itself resolve to a deploy key; a
40
+ * key/id-agnostic backend (the REST gateway) can ignore `kind` and address by `value`.
41
+ */
42
+ export function resolveFormIdentifier(
43
+ input: { formKey?: string; formId?: string },
44
+ ): { kind: "key" | "id"; value: string } | null {
45
+ const key = presentFormIdentifier(input.formKey);
46
+ if (key != null) return { kind: "key", value: key };
47
+ const id = presentFormIdentifier(input.formId);
48
+ if (id != null) return { kind: "id", value: id };
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Parse a deployed form-js schema into the object the surface renders. The engine
54
+ * serializes it as a JSON string, so parse it; tolerate an already-parsed object (an
55
+ * in-memory/embedded engine). Returns `null` for anything that is neither a JSON object
56
+ * string nor an object (which the caller treats as "no such form").
57
+ */
58
+ export function parseFormSchema(raw: unknown): Record<string, unknown> | null {
59
+ if (isRecord(raw)) return raw;
60
+ if (typeof raw !== "string") return null;
61
+ try {
62
+ const parsed: unknown = JSON.parse(raw);
63
+ return isRecord(parsed) ? parsed : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Assemble the {@link FormSchema} result with the contract's presence guards: a blank or
71
+ * whitespace-only `formKey`/`formId`, or a non-numeric `version`, is omitted rather than
72
+ * surfaced as an empty/garbage value. Identifiers are trimmed (the shared presence rule), so
73
+ * a padded `formKey` never leaks whitespace; `formKey` is stringified (the REST body may carry
74
+ * it as a number).
75
+ */
76
+ export function buildFormSchema(parts: {
77
+ schema: Record<string, unknown>;
78
+ formKey?: unknown;
79
+ formId?: unknown;
80
+ version?: unknown;
81
+ }): FormSchema {
82
+ const formKey = presentKey(parts.formKey);
83
+ const formId = typeof parts.formId === "string" ? presentFormIdentifier(parts.formId) : undefined;
84
+ return {
85
+ schema: parts.schema,
86
+ ...(formKey != null ? { formKey } : {}),
87
+ ...(formId != null ? { formId } : {}),
88
+ ...(typeof parts.version === "number" ? { version: parts.version } : {}),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Select a user task's form linkage from a raw engine record for
94
+ * {@link EngineClient.searchUserTasks}, applying the contract's non-empty rule (a blank
95
+ * `formKey`/`externalFormReference` is absent). The engine resolves a
96
+ * `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form's key at task
97
+ * creation, so a resolved `formKey` (not a form id) is the linkage a task reports.
98
+ *
99
+ * A key-addressed engine that does *not* itself resolve that linkage (the WASM adapter)
100
+ * can pass `resolveFormKeyByFormId` to map an authored `formId` on the task to the deploy
101
+ * key; a backend that already resolved it (the REST gateway) omits the resolver, and the
102
+ * authored-id path is simply not taken. A blank or whitespace-only `formKey`/`formId`/
103
+ * `externalFormReference` is treated as absent (the shared trimming presence rule).
104
+ */
105
+ export function pickFormLinkage(
106
+ raw: Record<string, unknown>,
107
+ resolveFormKeyByFormId?: (formId: string) => string | undefined,
108
+ ): { formKey?: string; externalFormReference?: string } {
109
+ const directKey = presentKey(raw.formKey);
110
+ const authoredId = typeof raw.formId === "string" ? presentFormIdentifier(raw.formId) : undefined;
111
+ const formKey =
112
+ directKey ?? (authoredId && resolveFormKeyByFormId ? resolveFormKeyByFormId(authoredId) : undefined);
113
+ const externalFormReference =
114
+ typeof raw.externalFormReference === "string"
115
+ ? presentFormIdentifier(raw.externalFormReference)
116
+ : undefined;
117
+ return {
118
+ ...(formKey ? { formKey } : {}),
119
+ ...(externalFormReference ? { externalFormReference } : {}),
120
+ };
121
+ }
@@ -194,10 +194,11 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
194
194
  const engine = new SdkEngineClient(client);
195
195
  const tasks = await engine.searchUserTasks({ processInstanceKey: "pi" });
196
196
  assert.deepEqual(consistency, { consistency: { waitUpToMs: 0 } });
197
- // The search is constrained to open (CREATED) tasks so terminal tasks never
198
- // surface as answerable, while caller filters (processInstanceKey) still apply.
199
- // Assert individual fields so the check is independent of key insertion order.
200
- assert.equal(input?.filter?.state, "CREATED");
197
+ // The adapter forwards the caller's filter verbatim and no longer injects a lifecycle
198
+ // state (#252): the search is unfiltered by state unless the caller opts in, so the
199
+ // taskInbox surface — not the adapter — owns the open-only (CREATED) policy. Assert
200
+ // individual fields so the check is independent of key insertion order.
201
+ assert.equal(input?.filter?.state, undefined, "no state is injected when the caller passes none");
201
202
  assert.equal(input?.filter?.processInstanceKey, "pi");
202
203
  assert.deepEqual(tasks, [
203
204
  { userTaskKey: "7", elementId: "task_a", variables: { x: 1 } },
@@ -205,6 +206,22 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
205
206
  ]);
206
207
  });
207
208
 
209
+ test("searchUserTasks forwards a caller-provided lifecycle state filter", async () => {
210
+ let input: { filter?: Record<string, unknown> } | undefined;
211
+ const client = fakeSdkClient({
212
+ searchUserTasks: async (i) => {
213
+ input = i;
214
+ return { items: [] };
215
+ },
216
+ });
217
+ const engine = new SdkEngineClient(client);
218
+ await engine.searchUserTasks({ state: "CREATED", processInstanceKey: "pi" });
219
+ // The caller's `state` reaches the engine unchanged — the adapter neither drops nor
220
+ // overrides it (the taskInbox surface passes `state: "CREATED"` to constrain the inbox).
221
+ assert.equal(input?.filter?.state, "CREATED");
222
+ assert.equal(input?.filter?.processInstanceKey, "pi");
223
+ });
224
+
208
225
  test("searchUserTasks surfaces the resolved form linkage", async () => {
209
226
  const client = fakeSdkClient({
210
227
  searchUserTasks: async () => ({
@@ -22,7 +22,13 @@ import type {
22
22
  UserTaskSummary,
23
23
  WorkerSubscription,
24
24
  } from "../core/host.ts";
25
- import { isBpmnError, presentFormIdentifier } from "../core/host.ts";
25
+ import { isBpmnError } from "../core/host.ts";
26
+ import {
27
+ buildFormSchema,
28
+ parseFormSchema,
29
+ pickFormLinkage,
30
+ resolveFormIdentifier,
31
+ } from "../core/form-contract.ts";
26
32
  import type { EngineSdkClient } from "./sdk.ts";
27
33
  import { isRecord } from "../core/guards.ts";
28
34
 
@@ -227,12 +233,7 @@ export class SdkEngineClient implements EngineClient {
227
233
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
228
234
  // the search reflects what is currently visible without blocking.
229
235
  const body = await this.client.searchUserTasks(
230
- // This method is contracted to return *open* tasks; constrain the read to
231
- // CREATED so answered/canceled tasks never surface as answerable (e.g. a
232
- // re-escalated instance whose prior escalation task is already COMPLETED).
233
- // Spread caller filters first and pin `state` last so a caller can never
234
- // override the open-only (CREATED) constraint this method contracts to.
235
- { filter: { ...(filter ?? {}), state: "CREATED" } },
236
+ { filter: { ...(filter ?? {}) } },
236
237
  { consistency: { waitUpToMs: 0 } },
237
238
  );
238
239
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
@@ -243,19 +244,13 @@ export class SdkEngineClient implements EngineClient {
243
244
  return [];
244
245
  }
245
246
  // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
246
- // task creation, so `formKey` (not a form id) is the linkage it reports on the task.
247
- const formKey =
248
- it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
249
- const externalFormReference =
250
- typeof it.externalFormReference === "string" && it.externalFormReference !== ""
251
- ? it.externalFormReference
252
- : undefined;
247
+ // task creation, so `formKey` (not a form id) is the linkage it reports on the task;
248
+ // no `resolveFormKeyByFormId` resolver is passed because the gateway already resolved it.
253
249
  return [{
254
250
  userTaskKey: String(userTaskKey),
255
251
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
256
252
  variables: isRecord(it.variables) ? it.variables : undefined,
257
- ...(formKey ? { formKey } : {}),
258
- ...(externalFormReference ? { externalFormReference } : {}),
253
+ ...pickFormLinkage(it),
259
254
  }];
260
255
  });
261
256
  }
@@ -268,12 +263,14 @@ export class SdkEngineClient implements EngineClient {
268
263
  // form or 404s (→ null) when there is no such form.
269
264
  //
270
265
  // This is the single gate that resolves which identifier addresses the form: an
271
- // empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
272
- // shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
266
+ // empty/whitespace identifier is treated as *absent* (via the shared presence rule in
267
+ // `resolveFormIdentifier`) so a blank `formKey` (e.g. a `?formKey=` query param) falls
273
268
  // through to a valid `formId` instead of being taken as a present-but-unresolvable key
274
- // that short-circuits to null.
275
- const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
276
- if (key == null) return null;
269
+ // that short-circuits to null. The REST gateway addresses a form by `value` regardless
270
+ // of whether it is a deploy key or an authored id, so `kind` is not consulted here.
271
+ const resolved = resolveFormIdentifier(input);
272
+ if (resolved == null) return null;
273
+ const key = resolved.value;
277
274
  let body: Record<string, unknown>;
278
275
  try {
279
276
  body = await this.client.getFormByKey(
@@ -290,27 +287,21 @@ export class SdkEngineClient implements EngineClient {
290
287
  return null;
291
288
  }
292
289
  if (!isRecord(body)) return null;
293
- const rawSchema = body.schema;
294
290
  // The engine serializes the form-js schema as a JSON string; parse it to the object
295
291
  // the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
296
- let schema: Record<string, unknown> | undefined;
297
- if (typeof rawSchema === "string") {
298
- try {
299
- const parsed: unknown = JSON.parse(rawSchema);
300
- if (isRecord(parsed)) schema = parsed;
301
- } catch {
292
+ const schema = parseFormSchema(body.schema);
293
+ if (!schema) {
294
+ if (typeof body.schema === "string") {
302
295
  this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
303
296
  }
304
- } else if (isRecord(rawSchema)) {
305
- schema = rawSchema;
297
+ return null;
306
298
  }
307
- if (!schema) return null;
308
- return {
299
+ return buildFormSchema({
309
300
  schema,
310
- ...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
311
- ...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
312
- ...(typeof body.version === "number" ? { version: body.version } : {}),
313
- };
301
+ formKey: body.formKey,
302
+ formId: body.formId,
303
+ version: body.version,
304
+ });
314
305
  }
315
306
 
316
307
  async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
@@ -52,16 +52,31 @@ export type { ValidationIssue } from "./core/validate.ts";
52
52
  export type {
53
53
  EngineClient,
54
54
  EngineJob,
55
+ FormSchema,
55
56
  HostContext,
56
57
  HttpHandler,
57
58
  HttpRequest,
58
59
  HttpResponse,
59
60
  HttpServer,
60
61
  JobHandler,
62
+ ProcessInstanceSnapshot,
63
+ ProcessInstanceState,
61
64
  SqliteDb,
65
+ UserTaskState,
66
+ UserTaskSummary,
62
67
  WatchHandle,
63
68
  WorkerSubscription,
64
69
  } from "./core/host.ts";
70
+ // Shared form/user-task contract normalization (issue #252): the single source of truth
71
+ // both engine adapters (SDK/REST + WASM test double) call, so their form-identifier,
72
+ // form-schema, and user-task form-linkage handling cannot drift.
73
+ export {
74
+ buildFormSchema,
75
+ parseFormSchema,
76
+ pickFormLinkage,
77
+ presentFormIdentifier,
78
+ resolveFormIdentifier,
79
+ } from "./core/form-contract.ts";
65
80
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
66
81
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
67
82
  // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).