@nanobpm/urban 0.49.1 → 0.51.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
+ }
@@ -498,6 +498,16 @@ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-lay
498
498
  neighbours. */
499
499
  table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
500
500
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
501
+ /* A cell's optional muted second line (col.subtitleField / subtitleTemplate):
502
+ the identity key rendered under the title, GitHub/Linear style. Plain DOM
503
+ text (never a link/href source), smaller and muted so the primary line leads. */
504
+ .pc-cell-sub { font-size:.8rem; color:var(--nano-text-muted); margin-top:.1rem; }
505
+ /* One-line clamp (col.truncate): stop a bounded column wrapping and show an
506
+ ellipsis, with the full value carried in a title=/aria-label tooltip. Needs a
507
+ bounded column (per-column width/weight) to clip against; a native <col> width
508
+ is honoured under table-layout:fixed. Applied to the primary and, when present,
509
+ the subtitle line so both stay single-line. */
510
+ .pc-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
501
511
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
502
512
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
503
513
  .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
@@ -987,6 +997,15 @@ function renderButton(node) {
987
997
  // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
988
998
  // the field text), so a template does not change badge behaviour. Sorting,
989
999
  // groupBy and orderBy also key off real fields, never the rendered template.
1000
+ //
1001
+ // Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
1002
+ // a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
1003
+ // second line under the primary text (the identity key beneath a title, GitHub/
1004
+ // Linear style) and 'truncate: true' to clamp each line to one row with an
1005
+ // ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
1006
+ // width/weight — see buildColgroup — to give the clip a bounded column). The
1007
+ // subtitle is ALWAYS plain DOM text, never an href source. With none of these
1008
+ // set the cell is a single inline node exactly as before (see cellTd).
990
1009
  function gridCell(col, row) {
991
1010
  // Raw single-field value: drives the badge presence gate + tooltip and any
992
1011
  // field-derived defaults. Kept separate from the (possibly templated) display
@@ -1016,17 +1035,28 @@ function gridCell(col, row) {
1016
1035
  el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1017
1036
  );
1018
1037
  }
1038
+ // Optional second line (col.subtitleField / subtitleTemplate): a muted key
1039
+ // under the primary title. subtitleTemplate wins (interpolated from the row);
1040
+ // else the raw subtitleField value. Absent/null → "" → no extra line. It is
1041
+ // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
1042
+ // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
1043
+ const subRaw =
1044
+ col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
1045
+ const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
1046
+ const truncate = col.truncate === true;
1047
+ // Primary inline content: an anchor for a valid link mode (checked in the same
1048
+ // badge < linkField < link(page) < link(processExplorer) precedence as before),
1049
+ // else the plain display text. Each link mode only claims the cell while
1050
+ // 'primary' is still the bare text — an invalid href falls through to the next
1051
+ // mode and ultimately to plain text, exactly as the early-return chain did.
1052
+ let primary = text;
1019
1053
  if (col.linkField) {
1020
1054
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
1021
1055
  if (text !== "" && /^https?:\/\//i.test(href)) {
1022
- return el(
1023
- "td",
1024
- {},
1025
- el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
1026
- );
1056
+ primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
1027
1057
  }
1028
1058
  }
1029
- if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1059
+ if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1030
1060
  // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1031
1061
  // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1032
1062
  // param tail, which parseRoute() decodes back verbatim; the page id is
@@ -1037,37 +1067,130 @@ function gridCell(col, row) {
1037
1067
  const keyStr = key == null ? "" : String(key).trim();
1038
1068
  if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1039
1069
  const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1040
- return el("td", {}, el("a", { class: "pc-link", href }, text));
1070
+ primary = el("a", { class: "pc-link", href }, text);
1041
1071
  }
1042
1072
  }
1043
- if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1073
+ if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1044
1074
  const key = row[col.link.keyField];
1045
1075
  const keyStr = key == null ? "" : String(key).trim();
1046
1076
  if (text !== "" && keyStr !== "") {
1047
1077
  const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
1048
- return el(
1049
- "td",
1050
- {},
1051
- el("a", {
1052
- class: "pc-link",
1053
- href,
1054
- target: "_blank",
1055
- rel: "noopener noreferrer",
1056
- // Embedded in the console a plain primary click routes the host's
1057
- // explorer in place (via the nano-navigate bridge) instead of opening
1058
- // a new window. A non-primary button (middle/right = new-tab/context
1059
- // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1060
- // any standalone run fall through to the native anchor above.
1061
- onclick: (ev) => {
1062
- if (ev.button !== 0) return;
1063
- if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1064
- if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1065
- },
1066
- }, text),
1067
- );
1078
+ primary = el("a", {
1079
+ class: "pc-link",
1080
+ href,
1081
+ target: "_blank",
1082
+ rel: "noopener noreferrer",
1083
+ // Embedded in the console a plain primary click routes the host's
1084
+ // explorer in place (via the nano-navigate bridge) instead of opening
1085
+ // a new window. A non-primary button (middle/right = new-tab/context
1086
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1087
+ // any standalone run fall through to the native anchor above.
1088
+ onclick: (ev) => {
1089
+ if (ev.button !== 0) return;
1090
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1091
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1092
+ },
1093
+ }, text);
1068
1094
  }
1069
1095
  }
1070
- return el("td", {}, text);
1096
+ return cellTd(primary, text, subText, truncate);
1097
+ }
1098
+
1099
+ // Compose a grid <td> from its primary inline content (a string or an anchor),
1100
+ // an optional muted subtitle line, and optional one-line truncation. With
1101
+ // NEITHER a subtitle nor truncation the cell is exactly the single inline node
1102
+ // as before, so existing grids render byte-for-byte unchanged (backward
1103
+ // compatible). When either is set the content is wrapped in a .pc-cell-main
1104
+ // line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
1105
+ // adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
1106
+ // into title=/aria-label so the clipped value is still recoverable on hover and
1107
+ // to assistive tech. When the primary is an element (a link cell's <a>), title=/
1108
+ // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
1109
+ // descendants, so hovering the link text or naming the anchor for assistive tech
1110
+ // would otherwise miss the full value the wrapper carries. The subtitle is set as
1111
+ // DOM text only — never an href.
1112
+ function cellTd(primary, primaryText, subText, truncate) {
1113
+ if (subText === "" && !truncate) return el("td", {}, primary);
1114
+ const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
1115
+ if (truncate && primaryText !== "") {
1116
+ mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
1117
+ if (primary && typeof primary.setAttribute === "function") {
1118
+ primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
1119
+ }
1120
+ }
1121
+ const main = el("div", mainAttrs, primary);
1122
+ if (subText === "") return el("td", {}, main);
1123
+ const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
1124
+ if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
1125
+ return el("td", {}, main, el("div", subAttrs, subText));
1126
+ }
1127
+
1128
+ // Parse a "<number>%" width into its numeric percentage, or null if the string
1129
+ // is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
1130
+ // remainder left for weighted columns can be computed.
1131
+ function colWidthPct(width) {
1132
+ const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
1133
+ return m ? parseFloat(m[1]) : null;
1134
+ }
1135
+
1136
+ // A column carries an explicit width only when it is a non-empty string. Single
1137
+ // source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
1138
+ // means (which columns win verbatim, consume the remainder, or use their weight).
1139
+ function hasExplicitWidth(col) {
1140
+ return typeof col.width === "string" && col.width !== "";
1141
+ }
1142
+
1143
+ // A column's <col> width for the grid's <colgroup>: an explicit col.width string
1144
+ // ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
1145
+ // normalised across the weighted columns into a percentage share of the width
1146
+ // left over by explicit columns (remainderPct); else null (no width — the column
1147
+ // shares the remainder equally under table-layout:fixed).
1148
+ function colWidthStyle(col, weightTotal, remainderPct) {
1149
+ if (hasExplicitWidth(col)) return col.width;
1150
+ if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
1151
+ return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
1152
+ }
1153
+ return null;
1154
+ }
1155
+
1156
+ // Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
1157
+ // splitting width equally. Returns null when no column declares width/weight, so
1158
+ // an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
1159
+ // adds trailing unsized <col>s for the row-actions/detail column so the sized
1160
+ // data columns stay aligned with their headers.
1161
+ function buildColgroup(cols, extraCount) {
1162
+ const hasSizing = cols.some(
1163
+ (c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
1164
+ );
1165
+ if (!hasSizing) return null;
1166
+ // Only columns that will actually use their weight (no explicit width — an
1167
+ // explicit width wins verbatim in colWidthStyle and never consumes weight)
1168
+ // contribute to weightTotal; counting a width-bearing column's weight here
1169
+ // would inflate the divisor and shrink the truly-weighted columns' share.
1170
+ let weightTotal = 0;
1171
+ for (const c of cols)
1172
+ if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
1173
+ // Weighted columns share only the width the explicit columns leave behind, so
1174
+ // mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
1175
+ // The remainder is only computable when every explicit width is a percentage;
1176
+ // if any uses an absolute/relative unit (rem/px) we can't subtract it from
1177
+ // 100%, so weighted columns fall back to a share of the full width.
1178
+ let explicitPctTotal = 0;
1179
+ let allExplicitArePct = true;
1180
+ for (const c of cols) {
1181
+ if (hasExplicitWidth(c)) {
1182
+ const pct = colWidthPct(c.width);
1183
+ if (pct === null) allExplicitArePct = false;
1184
+ else explicitPctTotal += pct;
1185
+ }
1186
+ }
1187
+ const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
1188
+ const colEls = cols.map((c) => {
1189
+ const w = colWidthStyle(c, weightTotal, remainderPct);
1190
+ return el("col", w ? { style: "width:" + w } : {});
1191
+ });
1192
+ for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
1193
+ return el("colgroup", {}, ...colEls);
1071
1194
  }
1072
1195
 
1073
1196
  function renderActionForm(node) {
@@ -1263,7 +1386,13 @@ function renderDataGrid(node) {
1263
1386
  if (hasExtra) headCells.push(el("th", {}, ""));
1264
1387
  const thead = el("thead", {}, el("tr", {}, ...headCells));
1265
1388
  const tbody = el("tbody", {});
1266
- const table = el("table", { class: "pc-grid" }, thead, tbody);
1389
+ // A <colgroup> (present only when a column declares width/weight) sizes the
1390
+ // columns so table-layout:fixed stops splitting width equally; it must precede
1391
+ // <thead> in the table.
1392
+ const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
1393
+ const table = colgroup
1394
+ ? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
1395
+ : el("table", { class: "pc-grid" }, thead, tbody);
1267
1396
  card.append(table);
1268
1397
  const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
1269
1398
 
@@ -1355,9 +1484,12 @@ function renderDataGrid(node) {
1355
1484
  if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1356
1485
  const ccols = cg.columns || [];
1357
1486
  const cbody = el("tbody", {});
1358
- const ctable = el("table", { class: "pc-grid" },
1359
- el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1360
- ...(cg.lazyField ? [el("th", {}, "")] : []))), cbody);
1487
+ const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1488
+ const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1489
+ ...(cg.lazyField ? [el("th", {}, "")] : [])));
1490
+ const ctable = ccolgroup
1491
+ ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1492
+ : el("table", { class: "pc-grid" }, cthead, cbody);
1361
1493
  wrap.append(ctable);
1362
1494
  try {
1363
1495
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
@@ -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.51.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",