@nanobpm/urban 0.49.0 → 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.
- package/dist/runtime/core/form-contract.d.ts +58 -0
- package/dist/runtime/core/form-contract.js +108 -0
- package/dist/runtime/core/host.d.ts +13 -2
- package/dist/runtime/core/modules/surfaces.js +8 -1
- package/dist/runtime/engine/nanosdk.d.ts +2 -1
- package/dist/runtime/engine/nanosdk.js +21 -32
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +4 -0
- package/package.json +1 -1
- package/src/runtime/core/form-contract.test.ts +105 -0
- package/src/runtime/core/form-contract.ts +121 -0
- package/src/runtime/core/host.ts +14 -2
- package/src/runtime/core/modules/surfaces.test.ts +17 -1
- package/src/runtime/core/modules/surfaces.ts +8 -1
- package/src/runtime/engine/nanosdk.test.ts +25 -1
- package/src/runtime/engine/nanosdk.ts +28 -30
- package/src/runtime/index.ts +15 -0
|
@@ -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
|
+
}
|
|
@@ -187,6 +187,13 @@ export interface WorkerSubscription {
|
|
|
187
187
|
* element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
|
|
188
188
|
*/
|
|
189
189
|
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
190
|
+
/**
|
|
191
|
+
* A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
|
|
192
|
+
* result. `CREATED` is the only *open* (answerable) state — a task in any other state has
|
|
193
|
+
* already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
|
|
194
|
+
* never surface in the `taskInbox` as if it were actionable.
|
|
195
|
+
*/
|
|
196
|
+
export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
|
|
190
197
|
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
191
198
|
export interface ProcessInstanceSnapshot {
|
|
192
199
|
readonly processInstanceKey: string;
|
|
@@ -253,12 +260,16 @@ export interface EngineClient {
|
|
|
253
260
|
correlationKey?: string;
|
|
254
261
|
variables?: Record<string, unknown>;
|
|
255
262
|
}): Promise<void>;
|
|
256
|
-
/** Search
|
|
257
|
-
*
|
|
263
|
+
/** Search user tasks (optionally by process instance, assignee, or candidate group).
|
|
264
|
+
* By default this is unfiltered by lifecycle state and may return tasks in any state
|
|
265
|
+
* (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
|
|
266
|
+
* (answerable) tasks. Each result carries the task's resolved form linkage
|
|
267
|
+
* (`formKey`/`externalFormReference`) when present. */
|
|
258
268
|
searchUserTasks(filter?: {
|
|
259
269
|
processInstanceKey?: string;
|
|
260
270
|
assignee?: string;
|
|
261
271
|
candidateGroup?: string;
|
|
272
|
+
state?: UserTaskState;
|
|
262
273
|
}): Promise<UserTaskSummary[]>;
|
|
263
274
|
/**
|
|
264
275
|
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
@@ -201,7 +201,14 @@ export function mountSurfaces(ctx, app) {
|
|
|
201
201
|
source: "surface:taskInbox",
|
|
202
202
|
handler: async (req) => {
|
|
203
203
|
const pik = req.query.get("processInstanceKey") ?? undefined;
|
|
204
|
-
|
|
204
|
+
// Constrain the inbox to open (answerable) tasks. Without a state filter the engine
|
|
205
|
+
// returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
|
|
206
|
+
// or withdrawn tasks would surface as if actionable — completing one then fails with
|
|
207
|
+
// "User task ... is not active". See nanobpm/nano-ide#248.
|
|
208
|
+
const tasks = await app.engine.searchUserTasks({
|
|
209
|
+
state: "CREATED",
|
|
210
|
+
...(pik ? { processInstanceKey: pik } : {}),
|
|
211
|
+
});
|
|
205
212
|
return json(tasks);
|
|
206
213
|
},
|
|
207
214
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -140,6 +140,7 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
140
140
|
processInstanceKey?: string;
|
|
141
141
|
assignee?: string;
|
|
142
142
|
candidateGroup?: string;
|
|
143
|
+
state?: UserTaskState;
|
|
143
144
|
}): Promise<UserTaskSummary[]>;
|
|
144
145
|
getForm(input: {
|
|
145
146
|
formKey?: string;
|
|
@@ -18,7 +18,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
18
18
|
}
|
|
19
19
|
return path;
|
|
20
20
|
};
|
|
21
|
-
import { isBpmnError
|
|
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. */
|
|
@@ -110,17 +111,13 @@ export class SdkEngineClient {
|
|
|
110
111
|
return [];
|
|
111
112
|
}
|
|
112
113
|
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
113
|
-
// task creation, so `formKey` (not a form id) is the linkage it reports on the task
|
|
114
|
-
|
|
115
|
-
const externalFormReference = typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
116
|
-
? it.externalFormReference
|
|
117
|
-
: 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.
|
|
118
116
|
return [{
|
|
119
117
|
userTaskKey: String(userTaskKey),
|
|
120
118
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
121
119
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
122
|
-
...(
|
|
123
|
-
...(externalFormReference ? { externalFormReference } : {}),
|
|
120
|
+
...pickFormLinkage(it),
|
|
124
121
|
}];
|
|
125
122
|
});
|
|
126
123
|
}
|
|
@@ -132,13 +129,15 @@ export class SdkEngineClient {
|
|
|
132
129
|
// form or 404s (→ null) when there is no such form.
|
|
133
130
|
//
|
|
134
131
|
// This is the single gate that resolves which identifier addresses the form: an
|
|
135
|
-
// empty/whitespace identifier is treated as *absent* (via
|
|
136
|
-
//
|
|
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
|
|
137
134
|
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
138
|
-
// that short-circuits to null.
|
|
139
|
-
|
|
140
|
-
|
|
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)
|
|
141
139
|
return null;
|
|
140
|
+
const key = resolved.value;
|
|
142
141
|
let body;
|
|
143
142
|
try {
|
|
144
143
|
body = await this.client.getFormByKey({ formKey: key }, { consistency: { waitUpToMs: 0 } });
|
|
@@ -154,31 +153,21 @@ export class SdkEngineClient {
|
|
|
154
153
|
}
|
|
155
154
|
if (!isRecord(body))
|
|
156
155
|
return null;
|
|
157
|
-
const rawSchema = body.schema;
|
|
158
156
|
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
159
157
|
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
const parsed = JSON.parse(rawSchema);
|
|
164
|
-
if (isRecord(parsed))
|
|
165
|
-
schema = parsed;
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
158
|
+
const schema = parseFormSchema(body.schema);
|
|
159
|
+
if (!schema) {
|
|
160
|
+
if (typeof body.schema === "string") {
|
|
168
161
|
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
169
162
|
}
|
|
170
|
-
}
|
|
171
|
-
else if (isRecord(rawSchema)) {
|
|
172
|
-
schema = rawSchema;
|
|
173
|
-
}
|
|
174
|
-
if (!schema)
|
|
175
163
|
return null;
|
|
176
|
-
|
|
164
|
+
}
|
|
165
|
+
return buildFormSchema({
|
|
177
166
|
schema,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
};
|
|
167
|
+
formKey: body.formKey,
|
|
168
|
+
formId: body.formId,
|
|
169
|
+
version: body.version,
|
|
170
|
+
});
|
|
182
171
|
}
|
|
183
172
|
async completeUserTask(userTaskKey, variables) {
|
|
184
173
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/runtime/index.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -217,6 +217,14 @@ export interface WorkerSubscription {
|
|
|
217
217
|
*/
|
|
218
218
|
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
|
|
222
|
+
* result. `CREATED` is the only *open* (answerable) state — a task in any other state has
|
|
223
|
+
* already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
|
|
224
|
+
* never surface in the `taskInbox` as if it were actionable.
|
|
225
|
+
*/
|
|
226
|
+
export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
|
|
227
|
+
|
|
220
228
|
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
221
229
|
export interface ProcessInstanceSnapshot {
|
|
222
230
|
readonly processInstanceKey: string;
|
|
@@ -277,12 +285,16 @@ export interface EngineClient {
|
|
|
277
285
|
correlationKey?: string;
|
|
278
286
|
variables?: Record<string, unknown>;
|
|
279
287
|
}): Promise<void>;
|
|
280
|
-
/** Search
|
|
281
|
-
*
|
|
288
|
+
/** Search user tasks (optionally by process instance, assignee, or candidate group).
|
|
289
|
+
* By default this is unfiltered by lifecycle state and may return tasks in any state
|
|
290
|
+
* (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
|
|
291
|
+
* (answerable) tasks. Each result carries the task's resolved form linkage
|
|
292
|
+
* (`formKey`/`externalFormReference`) when present. */
|
|
282
293
|
searchUserTasks(filter?: {
|
|
283
294
|
processInstanceKey?: string;
|
|
284
295
|
assignee?: string;
|
|
285
296
|
candidateGroup?: string;
|
|
297
|
+
state?: UserTaskState;
|
|
286
298
|
}): Promise<UserTaskSummary[]>;
|
|
287
299
|
/**
|
|
288
300
|
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
@@ -171,12 +171,28 @@ test("/api/tasks surfaces the resolved form linkage", async () => {
|
|
|
171
171
|
};
|
|
172
172
|
const { tasks } = inboxRoutes(engine);
|
|
173
173
|
const res = await call(tasks, { query: "processInstanceKey=pi-9" });
|
|
174
|
-
assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
|
|
174
|
+
assert.deepEqual(seenFilter, { state: "CREATED", processInstanceKey: "pi-9" });
|
|
175
175
|
const parsed = JSON.parse(String(res.body));
|
|
176
176
|
assert.equal(parsed[0].formKey, "form-123");
|
|
177
177
|
assert.equal(parsed[1].formKey, undefined);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
test("/api/tasks constrains the search to open (CREATED) tasks", async () => {
|
|
181
|
+
let seenFilter: unknown;
|
|
182
|
+
const engine: EngineClient = {
|
|
183
|
+
...fakeEngine,
|
|
184
|
+
searchUserTasks: async (filter) => {
|
|
185
|
+
seenFilter = filter;
|
|
186
|
+
return [];
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const { tasks } = inboxRoutes(engine);
|
|
190
|
+
await call(tasks);
|
|
191
|
+
// No processInstanceKey supplied, but the inbox must still pin state=CREATED so that
|
|
192
|
+
// already-answered/canceled tasks never surface as answerable (nanobpm/nano-ide#248).
|
|
193
|
+
assert.deepEqual(seenFilter, { state: "CREATED" });
|
|
194
|
+
});
|
|
195
|
+
|
|
180
196
|
test("/api/form resolves a linked form's schema", async () => {
|
|
181
197
|
let seen: unknown;
|
|
182
198
|
const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
|
|
@@ -215,7 +215,14 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
215
215
|
source: "surface:taskInbox",
|
|
216
216
|
handler: async (req) => {
|
|
217
217
|
const pik = req.query.get("processInstanceKey") ?? undefined;
|
|
218
|
-
|
|
218
|
+
// Constrain the inbox to open (answerable) tasks. Without a state filter the engine
|
|
219
|
+
// returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
|
|
220
|
+
// or withdrawn tasks would surface as if actionable — completing one then fails with
|
|
221
|
+
// "User task ... is not active". See nanobpm/nano-ide#248.
|
|
222
|
+
const tasks = await app.engine.searchUserTasks({
|
|
223
|
+
state: "CREATED",
|
|
224
|
+
...(pik ? { processInstanceKey: pik } : {}),
|
|
225
|
+
});
|
|
219
226
|
return json(tasks);
|
|
220
227
|
},
|
|
221
228
|
});
|
|
@@ -177,8 +177,10 @@ test("publishMessage defaults correlationKey/variables", async () => {
|
|
|
177
177
|
|
|
178
178
|
test("searchUserTasks passes zero-wait consistency and maps items", async () => {
|
|
179
179
|
let consistency: unknown;
|
|
180
|
+
let input: { filter?: Record<string, unknown> } | undefined;
|
|
180
181
|
const client = fakeSdkClient({
|
|
181
|
-
searchUserTasks: async (
|
|
182
|
+
searchUserTasks: async (i, c) => {
|
|
183
|
+
input = i;
|
|
182
184
|
consistency = c;
|
|
183
185
|
return {
|
|
184
186
|
items: [
|
|
@@ -192,12 +194,34 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
|
|
|
192
194
|
const engine = new SdkEngineClient(client);
|
|
193
195
|
const tasks = await engine.searchUserTasks({ processInstanceKey: "pi" });
|
|
194
196
|
assert.deepEqual(consistency, { consistency: { waitUpToMs: 0 } });
|
|
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");
|
|
202
|
+
assert.equal(input?.filter?.processInstanceKey, "pi");
|
|
195
203
|
assert.deepEqual(tasks, [
|
|
196
204
|
{ userTaskKey: "7", elementId: "task_a", variables: { x: 1 } },
|
|
197
205
|
{ userTaskKey: "8", elementId: undefined, variables: undefined },
|
|
198
206
|
]);
|
|
199
207
|
});
|
|
200
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
|
+
|
|
201
225
|
test("searchUserTasks surfaces the resolved form linkage", async () => {
|
|
202
226
|
const client = fakeSdkClient({
|
|
203
227
|
searchUserTasks: async () => ({
|
|
@@ -18,10 +18,17 @@ import type {
|
|
|
18
18
|
JobHandler,
|
|
19
19
|
ProcessInstanceSnapshot,
|
|
20
20
|
ProcessInstanceState,
|
|
21
|
+
UserTaskState,
|
|
21
22
|
UserTaskSummary,
|
|
22
23
|
WorkerSubscription,
|
|
23
24
|
} from "../core/host.ts";
|
|
24
|
-
import { isBpmnError
|
|
25
|
+
import { isBpmnError } from "../core/host.ts";
|
|
26
|
+
import {
|
|
27
|
+
buildFormSchema,
|
|
28
|
+
parseFormSchema,
|
|
29
|
+
pickFormLinkage,
|
|
30
|
+
resolveFormIdentifier,
|
|
31
|
+
} from "../core/form-contract.ts";
|
|
25
32
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
26
33
|
import { isRecord } from "../core/guards.ts";
|
|
27
34
|
|
|
@@ -221,6 +228,7 @@ export class SdkEngineClient implements EngineClient {
|
|
|
221
228
|
processInstanceKey?: string;
|
|
222
229
|
assignee?: string;
|
|
223
230
|
candidateGroup?: string;
|
|
231
|
+
state?: UserTaskState;
|
|
224
232
|
}): Promise<UserTaskSummary[]> {
|
|
225
233
|
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
226
234
|
// the search reflects what is currently visible without blocking.
|
|
@@ -236,19 +244,13 @@ export class SdkEngineClient implements EngineClient {
|
|
|
236
244
|
return [];
|
|
237
245
|
}
|
|
238
246
|
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
239
|
-
// task creation, so `formKey` (not a form id) is the linkage it reports on the task
|
|
240
|
-
|
|
241
|
-
it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
|
|
242
|
-
const externalFormReference =
|
|
243
|
-
typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
244
|
-
? it.externalFormReference
|
|
245
|
-
: 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.
|
|
246
249
|
return [{
|
|
247
250
|
userTaskKey: String(userTaskKey),
|
|
248
251
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
249
252
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
250
|
-
...(
|
|
251
|
-
...(externalFormReference ? { externalFormReference } : {}),
|
|
253
|
+
...pickFormLinkage(it),
|
|
252
254
|
}];
|
|
253
255
|
});
|
|
254
256
|
}
|
|
@@ -261,12 +263,14 @@ export class SdkEngineClient implements EngineClient {
|
|
|
261
263
|
// form or 404s (→ null) when there is no such form.
|
|
262
264
|
//
|
|
263
265
|
// This is the single gate that resolves which identifier addresses the form: an
|
|
264
|
-
// empty/whitespace identifier is treated as *absent* (via
|
|
265
|
-
//
|
|
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
|
|
266
268
|
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
267
|
-
// that short-circuits to null.
|
|
268
|
-
|
|
269
|
-
|
|
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;
|
|
270
274
|
let body: Record<string, unknown>;
|
|
271
275
|
try {
|
|
272
276
|
body = await this.client.getFormByKey(
|
|
@@ -283,27 +287,21 @@ export class SdkEngineClient implements EngineClient {
|
|
|
283
287
|
return null;
|
|
284
288
|
}
|
|
285
289
|
if (!isRecord(body)) return null;
|
|
286
|
-
const rawSchema = body.schema;
|
|
287
290
|
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
288
291
|
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
const parsed: unknown = JSON.parse(rawSchema);
|
|
293
|
-
if (isRecord(parsed)) schema = parsed;
|
|
294
|
-
} catch {
|
|
292
|
+
const schema = parseFormSchema(body.schema);
|
|
293
|
+
if (!schema) {
|
|
294
|
+
if (typeof body.schema === "string") {
|
|
295
295
|
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
296
296
|
}
|
|
297
|
-
|
|
298
|
-
schema = rawSchema;
|
|
297
|
+
return null;
|
|
299
298
|
}
|
|
300
|
-
|
|
301
|
-
return {
|
|
299
|
+
return buildFormSchema({
|
|
302
300
|
schema,
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
};
|
|
301
|
+
formKey: body.formKey,
|
|
302
|
+
formId: body.formId,
|
|
303
|
+
version: body.version,
|
|
304
|
+
});
|
|
307
305
|
}
|
|
308
306
|
|
|
309
307
|
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
|
package/src/runtime/index.ts
CHANGED
|
@@ -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(() => {})`).
|